beagle-turso 0.1.3 → 0.3.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: f49be5566a3b64416bccffc8c792e167e770139827016890070836eac6ab2484
4
+ data.tar.gz: c8be2046fb4e78a337008dcdd488f5b8587d754c77692d73e15f90dc0e3d2f8b
5
5
  SHA512:
6
- metadata.gz: ce2cef361285eed71718e587fbbe3a1a3d321e2e45ba269b1274edbbc622d073a0b55274ea2950e89e67eafeaceb70a0f3e469e0bd0bf29b59ac2ffedc205095
7
- data.tar.gz: edd3d0df3ce5ba628e4ca6923c9df228c9f480d9a05ba4bf879f95a599adeed5d362f38f10b8621ee1d0fa01a0ca07ae1a69e9b85065c0fd089b59e3510706be
6
+ metadata.gz: 591b0e8a2e9e71d767008bac9a2742a2c93e4bb74424f586d2af035339aa1510c6fb883e770b20440a9ad561b5c88e9b113f63f8b4b506355e18f4e39442ee23
7
+ data.tar.gz: 53782d7a3a82cffb627bdbc107ab022619cae5fe49a7fdc85d233aa04910497da7932de863b384d14199d77f63d35473021bf5adc8d95d5d97c13b00355d3145
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,120 @@ 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
+
155
+ ## Background sync
156
+
157
+ `Beagle::Turso::Syncer` keeps the remote copy current without a job system:
158
+
159
+ ```ruby
160
+ db = Beagle::Turso::Database.open(local_path: "app.db", remote_url: url, auth_token: token)
161
+
162
+ syncer = Beagle::Turso::Syncer.new(
163
+ db,
164
+ interval: 30, # seconds between cycles
165
+ pull: true, # pull after each push (false = push only)
166
+ max_backoff: 300, # retry delay cap, seconds
167
+ on_error: ->(error, status) { ErrorTracker.notify(error) },
168
+ logger: Logger.new($stderr),
169
+ ).start
170
+
171
+ syncer.status # => #<data Status running=true, syncing=false, last_push_at=..., ...>
172
+ syncer.sync_now # one cycle now, on this thread; :ok, or :busy if one is running
173
+ # (a failure is recorded, passed to on_error, then re-raised)
174
+
175
+ # on shutdown, before closing the database:
176
+ syncer.stop(timeout: 10) # waits for an in-flight sync, then pushes once more
177
+ # syncer.stop(timeout: 10, final_push: false) # waits, but doesn't push again
178
+ db.close
179
+ ```
180
+
181
+ - **Opt-in.** Requiring the gem starts nothing. `start` runs one background
182
+ thread. `push` and `pull` release the GVL, so that thread doesn't hold up
183
+ requests on other threads or a Falcon reactor.
184
+ - **Failures don't stop it.** Each failure is logged (at `warn`) and passed to
185
+ `on_error`. The next attempt waits `interval × 2ⁿ`, capped at
186
+ `max_backoff`, with ±20% jitter. A success resets the count. A failed push
187
+ skips that cycle's pull. A logger or `on_error` that raises a
188
+ `StandardError` is ignored (an `on_error` error is logged).
189
+ - **`sync_now` re-raises.** A failed `sync_now` records the failure in
190
+ `status`, logs it, calls `on_error`, then raises the error to you. Since
191
+ `on_error` has already seen it, don't report it again from a `rescue`. If
192
+ the error message carried a URL or credential, both `on_error` and the
193
+ caller get a copy of the error (same class and backtrace) with the redacted
194
+ message.
195
+ - **Status is for monitoring, not lag.** `last_push_at` says when a push last
196
+ succeeded, not how much is still unpushed. Alert when `consecutive_failures`
197
+ grows, or when `last_push_at` gets older than you can tolerate. The site keeps
198
+ serving from the local replica either way. `destination` is the remote host
199
+ only, and error messages are stripped of URLs and tokens.
200
+ - **Shutdown is best-effort.** `stop(timeout:, final_push: true)` stops the
201
+ loop, waits for a cycle in flight on any thread (the loop or a concurrent
202
+ `sync_now`), then pushes once more unless `final_push: false`. It waits up
203
+ to `timeout` seconds in total. A push or pull already in flight can't be
204
+ interrupted, so if the deadline passes, `stop` returns with
205
+ `status.syncing == true` and the work finishes in the background; process
206
+ exit then waits for that native call to return (until your platform's kill
207
+ timeout). `stop(timeout: 0)` skips the final push. Called from inside
208
+ `on_error`, `stop` can't wait for the cycle it is part of, so it returns
209
+ without a final push. If the network is down, the deadline passes, or the
210
+ process is killed, writes stay local until the next process pushes them.
211
+ `stop` never closes the database; do that after it returns.
212
+ - **Don't call `stop` from a signal handler.** Inside `Signal.trap` it raises
213
+ `ThreadError` (it takes locks). Call it from your server's shutdown hook or
214
+ `at_exit`.
215
+ - **One syncer per process, created after fork.** A stopped syncer can't be
216
+ restarted, so create a new one. With Puma in single mode, create it in an
217
+ initializer or `on_booted`; in cluster mode, create it in `on_worker_boot`
218
+ (each worker gets its own) and call `stop` from `on_worker_shutdown` or
219
+ `at_exit`.
220
+
106
221
  ## Credential safety
107
222
 
108
223
  `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
@@ -0,0 +1,41 @@
1
+ require "uri"
2
+
3
+ module Beagle
4
+ module Turso
5
+ # Strips credentials from text before it reaches a log, a status or a
6
+ # callback. Engine error messages can embed the remote URL; the auth token
7
+ # itself is sent as a header, but a URL can still carry userinfo or a
8
+ # token query parameter, so reduce every URL to scheme://host.
9
+ module Redaction
10
+ # scheme://[userinfo@]host[...]: keep scheme and host (a bracketed IPv6
11
+ # literal included), drop userinfo, port, path, query and fragment. The
12
+ # scheme length is bounded so a long run of "a.a.a." stays linear.
13
+ URL = %r{\b([a-z][a-z0-9+.-]{0,31})://(?:[^/\s?#"'@\[]+@)?(\[[0-9a-f:.]*\]|[^/\s?#"':\[]+)[^\s"']*}i
14
+ # Authorization schemes: keep the scheme word, blank the credential.
15
+ AUTH_SCHEME = /\b(Bearer|Basic)\s+[^\s"',;]+/i
16
+ # key=value / key: value / "key": "value" / key%3Dvalue, where the key is
17
+ # a whole word ending in a secret-ish name (turso_auth_token, x-api-key,
18
+ # access_token, ...). The lookbehind pins the key to a word start, which
19
+ # keeps the unbounded prefix linear.
20
+ SECRET_PARAM = /(?<![\w-])([\w-]*?(?:auth_?token|token|jwt|api[_-]?key|password|passwd|secret|authorization))(["']?\s*(?:[=:]|%3D)\s*["']?)(?!(?:Bearer|Basic)\s)[^\s"'&,;]+/i
21
+ # A bare JWT (header.payload.signature, base64url).
22
+ JWT = /\beyJ[\w-]+\.[\w-]+\.[\w-]+/
23
+
24
+ module_function
25
+
26
+ def message(text)
27
+ text.to_s
28
+ .gsub(URL) { "#{Regexp.last_match(1)}://#{Regexp.last_match(2)}" }
29
+ .gsub(JWT, "[redacted]")
30
+ .gsub(AUTH_SCHEME, '\1 [redacted]')
31
+ .gsub(SECRET_PARAM) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)}[redacted]" }
32
+ end
33
+
34
+ def host(url)
35
+ URI.parse(url.to_s).host
36
+ rescue URI::InvalidURIError
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,293 @@
1
+ module Beagle
2
+ module Turso
3
+ # Keeps a synced Database's remote copy current: runs push (then pull) on
4
+ # a background thread, backs off on failure, reports status, and pushes
5
+ # one last time on stop. Framework-neutral and opt-in: nothing runs until
6
+ # #start, and it never closes the database (its owner does, after #stop).
7
+ #
8
+ # push/pull release the GVL (see the README), so the syncer's thread does
9
+ # not stall other Ruby threads or a fiber scheduler's reactor.
10
+ class Syncer
11
+ Status = Data.define(
12
+ :running, :syncing,
13
+ :last_push_at, :last_pull_at,
14
+ :last_error_class, :last_error_message, :last_error_at,
15
+ :consecutive_failures, :destination
16
+ )
17
+
18
+ # Retry delays are spread by ±JITTER so restarted processes don't retry
19
+ # in lockstep.
20
+ JITTER = 0.2
21
+
22
+ # Backoff stops growing past interval * 2**MAX_EXPONENT (max_backoff
23
+ # caps it long before), so a long outage never builds a bignum.
24
+ MAX_EXPONENT = 32
25
+
26
+ # How often a final push polls for an in-flight cycle to finish.
27
+ LOCK_POLL = 0.005
28
+
29
+ def initialize(database, interval: 30, pull: true, max_backoff: 300, on_error: nil, logger: nil, random: Random.new)
30
+ unless database.synced?
31
+ raise NotSyncedError, "Syncer needs a synced database (opened with remote_url: and auth_token:)"
32
+ end
33
+ raise ArgumentError, "interval must be positive" unless interval.positive?
34
+ raise ArgumentError, "max_backoff must be at least interval" if max_backoff < interval
35
+
36
+ @database = database
37
+ @interval = interval
38
+ @pull = pull
39
+ @max_backoff = max_backoff
40
+ @on_error = on_error
41
+ @logger = logger
42
+ @random = random
43
+
44
+ @cycle_lock = Mutex.new # held for the whole of one push/pull cycle
45
+ @state_lock = Mutex.new # guards the fields below and the wake-up signal
46
+ @wake = ConditionVariable.new
47
+ @thread = nil
48
+ @stopping = false
49
+ @stopped = false
50
+ @syncing = false
51
+ @last_push_at = nil
52
+ @last_pull_at = nil
53
+ @last_error_class = nil
54
+ @last_error_message = nil
55
+ @last_error_at = nil
56
+ @consecutive_failures = 0
57
+ end
58
+
59
+ # Runs one cycle on the calling thread. Returns :ok, or :busy (without
60
+ # waiting) when another cycle is in progress. A failure is recorded in
61
+ # #status, reported to on_error/logger, then re-raised. If the message
62
+ # carried credentials, both on_error and the caller get a copy of the
63
+ # error (same class and backtrace) with the redacted message instead.
64
+ def sync_now
65
+ cycle(pull: @pull)
66
+ end
67
+
68
+ def status
69
+ @state_lock.synchronize do
70
+ Status.new(
71
+ running: loop_running?,
72
+ syncing: @syncing,
73
+ last_push_at: @last_push_at,
74
+ last_pull_at: @last_pull_at,
75
+ last_error_class: @last_error_class,
76
+ last_error_message: @last_error_message,
77
+ last_error_at: @last_error_at,
78
+ consecutive_failures: @consecutive_failures,
79
+ destination: @database.remote_host
80
+ )
81
+ end
82
+ end
83
+
84
+ # Starts the background thread. Idempotent while running. A stopped
85
+ # syncer cannot be restarted; create a new one.
86
+ def start
87
+ @state_lock.synchronize do
88
+ raise Error, "syncer has been stopped; create a new one" if @stopped
89
+ return self if @thread
90
+
91
+ @thread = Thread.new { run_loop }
92
+ @thread.name = "beagle-turso-syncer"
93
+ end
94
+ self
95
+ end
96
+
97
+ # True while the background thread is alive and not stopping. A loop
98
+ # thread that died (e.g. on_error raised a non-StandardError) is not
99
+ # running.
100
+ def running?
101
+ @state_lock.synchronize { loop_running? }
102
+ end
103
+
104
+ # Stops the loop, waits for a cycle in flight on any other thread
105
+ # (the loop or a concurrent sync_now), then, if final_push and the
106
+ # deadline allows, pushes once more. Waits at most `timeout` seconds in
107
+ # total: an in-flight push or pull cannot be interrupted, so if the
108
+ # deadline passes first this returns anyway with status.syncing true and
109
+ # the work finishes in the background. Called from inside a cycle (from
110
+ # on_error), it cannot wait for that cycle and skips the final push.
111
+ # Never closes the database. Idempotent.
112
+ def stop(timeout: 10, final_push: true)
113
+ unless timeout.is_a?(Numeric) && timeout >= 0
114
+ raise ArgumentError, "timeout must be a non-negative number of seconds"
115
+ end
116
+
117
+ first = @state_lock.synchronize do
118
+ next false if @stopped
119
+
120
+ @stopped = true
121
+ @stopping = true
122
+ @wake.broadcast
123
+ true
124
+ end
125
+ return status unless first
126
+
127
+ deadline = monotonic_now + timeout
128
+ # Joining our own thread (stop called from on_error) would raise; that
129
+ # thread exits by itself once the callback returns.
130
+ join_quietly(@thread, deadline) if @thread && @thread != Thread.current
131
+ # Inside a cycle (stop from on_error) this thread holds the cycle lock,
132
+ # so waiting for it would only burn the deadline.
133
+ unless @cycle_lock.owned? || loop_alive?
134
+ if final_push && remaining(deadline).positive?
135
+ pusher = Thread.new do
136
+ cycle(pull: false, wait_until: deadline)
137
+ rescue StandardError
138
+ nil # already recorded, logged and reported by cycle
139
+ end
140
+ join_quietly(pusher, deadline)
141
+ elsif lock_by(deadline)
142
+ @cycle_lock.unlock # only waiting for the in-flight cycle
143
+ end
144
+ end
145
+ status
146
+ end
147
+
148
+ private
149
+
150
+ def run_loop
151
+ loop do
152
+ return if @state_lock.synchronize { @stopping }
153
+
154
+ delay =
155
+ begin
156
+ cycle(pull: @pull)
157
+ @interval
158
+ rescue StandardError
159
+ retry_delay(@state_lock.synchronize { @consecutive_failures })
160
+ end
161
+
162
+ @state_lock.synchronize do
163
+ return if @stopping
164
+
165
+ @wake.wait(@state_lock, delay)
166
+ end
167
+ end
168
+ end
169
+
170
+ def loop_alive?
171
+ @thread&.alive? && @thread != Thread.current
172
+ end
173
+
174
+ # Caller holds @state_lock.
175
+ def loop_running?
176
+ !!@thread&.alive? && !@stopping
177
+ end
178
+
179
+ # Joins a helper thread until the deadline. A thread that died from an
180
+ # exception re-raises it here; stop must still finish, so log it.
181
+ def join_quietly(thread, deadline)
182
+ thread.join(remaining(deadline))
183
+ rescue Exception => e # rubocop:disable Lint/RescueException
184
+ raise if thread.alive? # not the thread's death: e.g. an interrupt aimed at us
185
+
186
+ safe_warn("beagle-turso syncer thread died (#{e.class}): #{Redaction.message(e.message)}")
187
+ end
188
+
189
+ # Takes the cycle lock, waiting for an in-flight cycle until the
190
+ # deadline. False if the deadline passed first.
191
+ def lock_by(deadline)
192
+ until @cycle_lock.try_lock
193
+ left = remaining(deadline)
194
+ return false unless left.positive?
195
+
196
+ sleep([LOCK_POLL, left].min)
197
+ end
198
+ true
199
+ end
200
+
201
+ def remaining(deadline)
202
+ [deadline - monotonic_now, 0].max
203
+ end
204
+
205
+ def monotonic_now
206
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
207
+ end
208
+
209
+ # One push (then pull) under the cycle lock. Without wait_until it never
210
+ # waits (returns :busy); with it, it waits for the lock until then.
211
+ def cycle(pull:, wait_until: nil)
212
+ locked = wait_until ? lock_by(wait_until) : @cycle_lock.try_lock
213
+ return :busy unless locked
214
+
215
+ begin
216
+ set_syncing(true)
217
+ @database.push
218
+ @state_lock.synchronize { @last_push_at = Time.now }
219
+ if pull
220
+ @database.pull
221
+ @state_lock.synchronize { @last_pull_at = Time.now }
222
+ end
223
+ @state_lock.synchronize { @consecutive_failures = 0 }
224
+ :ok
225
+ rescue StandardError => e
226
+ reported = record_failure(e)
227
+ raise e if reported.equal?(e)
228
+
229
+ raise reported, cause: nil # the cause would carry the unredacted original
230
+ ensure
231
+ begin
232
+ set_syncing(false)
233
+ ensure
234
+ @cycle_lock.unlock
235
+ end
236
+ end
237
+ end
238
+
239
+ def set_syncing(value)
240
+ @state_lock.synchronize { @syncing = value }
241
+ end
242
+
243
+ # Records, logs and reports a failure. Returns the error to hand on:
244
+ # the original, or a redacted copy if its message carried credentials.
245
+ def record_failure(error)
246
+ message = Redaction.message(error.message).freeze
247
+ @state_lock.synchronize do
248
+ @consecutive_failures += 1
249
+ @last_error_class = error.class.to_s
250
+ @last_error_message = message
251
+ @last_error_at = Time.now
252
+ end
253
+ safe_warn("beagle-turso sync failed (#{error.class}): #{message}")
254
+ reported = redacted_error(error, message)
255
+ notify(reported)
256
+ reported
257
+ end
258
+
259
+ def redacted_error(error, message)
260
+ return error if message == error.message
261
+
262
+ copy = begin
263
+ error.exception(message)
264
+ rescue StandardError
265
+ nil
266
+ end
267
+ copy = Error.new(message) unless copy.is_a?(Exception) && copy.message == message
268
+ copy.set_backtrace(error.backtrace) if copy.backtrace.nil? && error.backtrace
269
+ copy
270
+ end
271
+
272
+ def notify(error)
273
+ @on_error&.call(error, status)
274
+ rescue StandardError => e
275
+ safe_warn("beagle-turso syncer on_error raised (#{e.class}): #{Redaction.message(e.message)}")
276
+ end
277
+
278
+ # A broken logger must not skip on_error or replace the sync error.
279
+ def safe_warn(message)
280
+ @logger&.warn(message)
281
+ rescue StandardError
282
+ nil
283
+ end
284
+
285
+ # Delay before the next attempt after `failures` consecutive failures:
286
+ # interval * 2**failures, capped at max_backoff, then spread by ±JITTER.
287
+ def retry_delay(failures)
288
+ base = [@interval * (2**[failures, MAX_EXPONENT].min), @max_backoff].min
289
+ base * (1 + @random.rand(-JITTER..JITTER))
290
+ end
291
+ end
292
+ end
293
+ end
@@ -1,5 +1,5 @@
1
1
  module Beagle
2
2
  module Turso
3
- VERSION = "0.1.3"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
data/lib/beagle/turso.rb CHANGED
@@ -1,4 +1,7 @@
1
1
  require_relative "turso/version"
2
+ require_relative "turso/errors"
3
+ require_relative "turso/redaction"
4
+ require_relative "turso/syncer"
2
5
 
3
6
  # Load the native extension. Precompiled ("fat") gems ship the .so under a
4
7
  # per-Ruby-ABI subdir (lib/beagle_turso/<major.minor>/beagle_turso.so); a
@@ -16,12 +19,28 @@ module Beagle
16
19
  # Opens a local-only database, or one synced with a remote Turso
17
20
  # database when both `remote_url` and `auth_token` are given (see
18
21
  # `#push`/`#pull`). Delegates to the native `_open` primitive, which
19
- # takes its arguments positionally.
22
+ # takes its arguments positionally. Only the remote host is kept on
23
+ # the Ruby object; the token is never stored.
20
24
  def self.open(local_path:, remote_url: nil, auth_token: nil, bootstrap_if_empty: true)
21
- _open(local_path, remote_url, auth_token, bootstrap_if_empty)
25
+ db = _open(local_path, remote_url, auth_token, bootstrap_if_empty)
26
+ if remote_url && auth_token
27
+ db.instance_variable_set(:@synced, true)
28
+ db.instance_variable_set(:@remote_host, Redaction.host(remote_url))
29
+ end
30
+ db
22
31
  end
23
32
 
24
33
  private_class_method :_open
34
+
35
+ # True when opened with both remote_url: and auth_token:, i.e. when
36
+ # #push/#pull talk to a remote.
37
+ def synced?
38
+ @synced == true
39
+ end
40
+
41
+ # The remote database's host, without scheme, path, query or
42
+ # credentials; nil for a local-only database.
43
+ attr_reader :remote_host
25
44
  end
26
45
  end
27
46
  end
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.3.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,13 @@ 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
48
+ - lib/beagle/turso/redaction.rb
49
+ - lib/beagle/turso/syncer.rb
45
50
  - lib/beagle/turso/version.rb
46
51
  homepage: https://github.com/BeagleSoftwareUK/beagle-turso
47
52
  licenses: