activerecord-beagle-turso 0.1.8 → 0.1.10

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: cd2ebde88afce8658ba27c6a8ae41ffb3228e6ec7bb6c65be6ff0a5a1c56d6ec
4
- data.tar.gz: 2c6ac050108dd08d75808fb5aa28854164f91835c6529ff400ff97351d81c449
3
+ metadata.gz: 85d72dcc7cf48c86d50b2e81ec3d481b0ab33c1aa3a9f1425574a82d3662c94a
4
+ data.tar.gz: 05c6675f967450f5f0aa66c22e0bf0244da1ed4874645d93265f13e1a0a40a77
5
5
  SHA512:
6
- metadata.gz: 0cc05e0873fd0a0d143c2bae7758320768b06bdca6f6cd676de504cc09bc61360877aa81fa7d4b88ca7836758103135d3056d15cc5587721481b826fd9d0855c
7
- data.tar.gz: d0be294e7c35be6e5275cbbbcb54e80546ddb1b4c30a6f6ea32b72ec88c8554eded57b6537e128581ce599c3d1c173e49c443f7ca4e88ba7366641d351b9916d
6
+ metadata.gz: '0937eb27407a0acf3ff4ce564181d949dc0578559faba7f76a49b41c0a597b47add6d34881df708cbae16e8629b40dcf6113fbbaed00d7703756da7c2cfca585'
7
+ data.tar.gz: 719662f8a03bc87f2a8b29f4f9f5d0e7ca48c66323f3b3258a67abd0f833e53a08c4df1a4db5e899a45ad696a261ef2ac4d2bbe3fa6dc5fd73207e4e7f69aa51
data/README.md CHANGED
@@ -68,7 +68,10 @@ does not cover them automatically. Source them from encrypted credentials or
68
68
  transaction to finish before raising `Beagle::Turso::BusyError`. Transactions
69
69
  begin with `BEGIN IMMEDIATE`, so a writer waits at `BEGIN`, never part-way
70
70
  through. The wait sleeps in Ruby, so other threads and fibers keep running.
71
- Without `timeout:` (or with 0), a busy write raises at once.
71
+ Opening a new connection waits the same way when a synced replica is busy
72
+ with a sync (the engine reports "database tape error: database is busy" as an
73
+ `OpenError` or `ConnectError`), so a pool growing during a `SyncJob` no
74
+ longer fails. Without `timeout:` (or with 0), both raise at once.
72
75
 
73
76
  An optional `bootstrap_if_empty:` (default `true`) controls whether a brand
74
77
  new/empty local file is populated from the remote on first open — see
@@ -247,6 +250,17 @@ this gem.
247
250
  each sync — pick a sync cadence accordingly, or run Solid Queue in a separate
248
251
  process.
249
252
 
253
+ 4. **One process per database file.** A local Turso database file is locked to
254
+ the process that opened it (SQLite lets processes share a file; Turso does
255
+ not), so a second process opening it fails with `OpenError` ("... File is
256
+ locked by another process") while the first holds a connection. The common
257
+ case is handled: `bin/rails db:test:prepare test` (or `... spec`) runs the
258
+ tests in a child process, so when a `db:*` rake task finishes the adapter
259
+ releases its own file-backed connections in the rake process
260
+ (`BeagleTurso::TestProcessRelease`). In-memory databases, other adapters'
261
+ connections and `Beagle::Turso::Database`s opened outside ActiveRecord are
262
+ left alone.
263
+
250
264
  ## Known engine issue: self-referencing foreign keys
251
265
 
252
266
  Turso's engine has open bugs with foreign keys that reference their own table
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "active_record/connection_adapters/beagle_turso/errors"
4
4
  require "active_record/connection_adapters/beagle_turso/sync_manager"
5
+ require "active_record/connection_adapters/beagle_turso/test_process_release"
5
6
 
6
7
  module ActiveRecord
7
8
  module ConnectionAdapters
@@ -199,6 +200,7 @@ module ActiveRecord
199
200
  rake_tasks do
200
201
  require "rake"
201
202
  MigrationPush.install!
203
+ TestProcessRelease.install!
202
204
  end
203
205
  end
204
206
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module BeagleTurso
6
+ # Lets `bin/rails db:test:prepare test` -- the Rails CI default -- run as
7
+ # one command (and `db:test:prepare spec`, or any task after it that
8
+ # starts a process of its own).
9
+ #
10
+ # The rake test tasks do not run the tests in the rake process:
11
+ # Rails::TestUnit::Runner.run_from_rake starts a separate `rails test`
12
+ # process and waits for it (rspec-rails' `spec` task does the same). A
13
+ # local Turso database file is locked to the process that opened it --
14
+ # SQLite lets processes share a file, Turso does not -- and
15
+ # db:test:prepare left its connection open, so the test process could
16
+ # not open the database:
17
+ #
18
+ # open failed: Locking error: Failed locking file '.../storage/test.sqlite3'.
19
+ # File is locked by another process
20
+ #
21
+ # So when an outermost db:* task finishes, this adapter's file-backed
22
+ # connections in the rake process are released. A pool reconnects on
23
+ # its next use. In-memory databases are left alone (closing one would
24
+ # destroy it, and no other process can open it anyway), as are other
25
+ # adapters' connections.
26
+ module TestProcessRelease
27
+ # Prepended onto Rake::Task so it works whatever order the Railtie and
28
+ # ActiveRecord's own rake tasks load in (same approach as MigrationPush).
29
+ module TaskHook
30
+ def execute(args = nil)
31
+ if name.start_with?("db:")
32
+ TestProcessRelease.around { super }
33
+ else
34
+ super
35
+ end
36
+ end
37
+ end
38
+
39
+ class << self
40
+ def install!
41
+ return if ::Rake::Task.ancestors.include?(TaskHook)
42
+ ::Rake::Task.prepend(TaskHook)
43
+ end
44
+
45
+ # Releases once, when the outermost db:* task finishes: a db task
46
+ # that invokes others (db:test:prepare -> db:test:load_schema) keeps
47
+ # its connection until it is done.
48
+ def around
49
+ self.depth = depth + 1
50
+ yield
51
+ ensure
52
+ self.depth = depth - 1
53
+ release_connections! if depth.zero?
54
+ end
55
+
56
+ def release_connections!
57
+ # Not loaded means nothing connected; don't load it to find out.
58
+ return unless defined?(::ActiveRecord::Base) && !::ActiveRecord.autoload?(:Base)
59
+
60
+ ::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
61
+ pool.disconnect! if releasable?(pool.db_config)
62
+ end
63
+ end
64
+
65
+ def releasable?(db_config)
66
+ db_config.adapter.to_s == "beagle_turso" && db_config.database.to_s != ":memory:"
67
+ end
68
+
69
+ def depth = Thread.current[:beagle_turso_test_process_release_depth].to_i
70
+ def depth=(value)
71
+ Thread.current[:beagle_turso_test_process_release_depth] = value
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -74,12 +74,87 @@ module ActiveRecord
74
74
  INSERT_STATEMENT_REGEX = /\A\s*(?:(?:INSERT|REPLACE)\b|WITH\b[\s\S]*\)\s*(?:INSERT|REPLACE)\b)/i
75
75
  private_constant :INSERT_STATEMENT_REGEX
76
76
 
77
+ # A statement that lost the single-writer race. A stale snapshot is
78
+ # never retried: the transaction that read it has to start again.
79
+ STATEMENT_BUSY = lambda do |error|
80
+ error.is_a?(::Beagle::Turso::BusyError) && !error.message.include?("snapshot is stale")
81
+ end
82
+ private_constant :STATEMENT_BUSY
83
+
84
+ # Opening or connecting to a synced replica while another connection
85
+ # syncs it. The sync engine reports that as "database tape error:
86
+ # database is busy" inside an OpenError or ConnectError (the driver only
87
+ # classifies the engine's own "Database is busy" as BusyError).
88
+ OPEN_BUSY = lambda do |error|
89
+ STATEMENT_BUSY.call(error) ||
90
+ ((error.is_a?(::Beagle::Turso::OpenError) || error.is_a?(::Beagle::Turso::ConnectError)) &&
91
+ error.message.match?(/database is busy/i))
92
+ end
93
+ private_constant :OPEN_BUSY
94
+
77
95
  class << self
78
96
  # Open a beagle-turso Database (synced remote when +remote_url+ is
79
97
  # present, otherwise local/in-memory) and wrap it so the SQLite3Adapter
80
- # can drive it as a raw connection.
98
+ # can drive it as a raw connection. A busy open is retried for
99
+ # database.yml's `timeout:`, like a busy statement.
81
100
  def new_client(config)
82
- database =
101
+ busy = ->(error) { OPEN_BUSY.call(error) || torn_meta_read?(error, config) }
102
+ retry_busy(busy_timeout(config[:timeout]), busy: busy) do
103
+ database = open_database(config)
104
+ begin
105
+ Client.new(database)
106
+ rescue Exception
107
+ database.close
108
+ raise
109
+ end
110
+ end
111
+ end
112
+
113
+ # `timeout:` (ms, as in database.yml) as seconds; nil means no waiting.
114
+ def busy_timeout(milliseconds)
115
+ milliseconds.to_i.positive? ? milliseconds.to_i / 1000.0 : nil
116
+ end
117
+
118
+ # Retries the block while it raises an error +busy+ accepts, with
119
+ # capped exponential backoff, for up to +timeout+ seconds. The engine
120
+ # never waits for a lock itself (from beagle-turso 0.4.0 it raises at
121
+ # once), so this is where the wait happens. sleep releases the GVL and
122
+ # yields to a fiber scheduler, so whoever holds the lock can finish.
123
+ def retry_busy(timeout, busy:)
124
+ deadline = nil
125
+ delay = 0.002
126
+ begin
127
+ yield
128
+ rescue ::Beagle::Turso::Error => e
129
+ raise if timeout.nil? || !busy.call(e)
130
+
131
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
132
+ deadline ||= now + timeout
133
+ raise if now + delay > deadline
134
+
135
+ sleep delay
136
+ delay = [delay * 2, 0.1].min
137
+ retry
138
+ end
139
+ end
140
+
141
+ def synced_config?(config)
142
+ !config[:remote_url].to_s.empty? && !config[:auth_token].to_s.empty?
143
+ end
144
+
145
+ private
146
+ # The sync engine (turso 0.8.3) rewrites a replica's metadata file
147
+ # (`<database>-info`) in place, truncating it first, so an open that
148
+ # reads it mid-sync can find it empty and fail with "meta must be
149
+ # initialized before open". Retried only when the file is there: a
150
+ # replica that was never initialised fails at once.
151
+ def torn_meta_read?(error, config)
152
+ error.is_a?(::Beagle::Turso::OpenError) &&
153
+ error.message.include?("meta must be initialized before open") &&
154
+ File.exist?("#{config[:database]}-info")
155
+ end
156
+
157
+ def open_database(config)
83
158
  if config[:remote_url]
84
159
  Beagle::Turso::Database.open(
85
160
  local_path: config[:database].to_s,
@@ -90,12 +165,7 @@ module ActiveRecord
90
165
  else
91
166
  Beagle::Turso::Database.open_local(config[:database].to_s)
92
167
  end
93
- Client.new(database)
94
- end
95
-
96
- def synced_config?(config)
97
- !config[:remote_url].to_s.empty? && !config[:auth_token].to_s.empty?
98
- end
168
+ end
99
169
  end
100
170
 
101
171
  # Adapts a Beagle::Turso::Database + its connected Connection to the subset
@@ -187,31 +257,14 @@ module ActiveRecord
187
257
  # does to the sqlite3 gem's busy handler. Unset or 0 means no waiting:
188
258
  # a BusyError surfaces at once, as with the sqlite3 gem.
189
259
  def busy_handler_timeout=(milliseconds)
190
- @busy_timeout = milliseconds.to_i.positive? ? milliseconds.to_i / 1000.0 : nil
260
+ @busy_timeout = BeagleTursoAdapter.busy_timeout(milliseconds)
191
261
  end
192
262
 
193
- # Retries a statement that lost the single-writer race, with capped
194
- # exponential backoff, for up to the busy timeout. The engine never
195
- # waits for a lock itself (from beagle-turso 0.4.0 it raises at once),
196
- # so this is where the wait happens. sleep releases the GVL and yields
197
- # to a fiber scheduler, so the writer holding the lock can finish. The
198
- # caller decides what is safe to retry (see BeagleTursoAdapter#perform_query).
199
- def retry_busy
200
- deadline = nil
201
- delay = 0.002
202
- begin
203
- yield
204
- rescue ::Beagle::Turso::BusyError => e
205
- raise if @busy_timeout.nil? || e.message.include?("snapshot is stale")
206
-
207
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
208
- deadline ||= now + @busy_timeout
209
- raise if now + delay > deadline
210
-
211
- sleep delay
212
- delay = [delay * 2, 0.1].min
213
- retry
214
- end
263
+ # Retries a statement that lost the single-writer race for up to the
264
+ # busy timeout (see BeagleTursoAdapter.retry_busy). The caller decides
265
+ # what is safe to retry (see BeagleTursoAdapter#perform_query).
266
+ def retry_busy(&)
267
+ BeagleTursoAdapter.retry_busy(@busy_timeout, busy: STATEMENT_BUSY, &)
215
268
  end
216
269
 
217
270
  # --- sqlite3-gem lifecycle shims the SQLite3Adapter drives ---
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module BeagleTurso
5
- VERSION = "0.1.8"
5
+ VERSION = "0.1.10"
6
6
  end
7
7
  end
@@ -6,3 +6,4 @@ require "active_record/connection_adapters/beagle_turso/sync_manager"
6
6
  require "active_record/connection_adapters/beagle_turso/sync_job"
7
7
  require "active_record/connection_adapters/beagle_turso/errors"
8
8
  require "active_record/connection_adapters/beagle_turso/migration_push"
9
+ require "active_record/connection_adapters/beagle_turso/test_process_release"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-beagle-turso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
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-18 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -97,6 +97,7 @@ files:
97
97
  - lib/active_record/connection_adapters/beagle_turso/migration_push.rb
98
98
  - lib/active_record/connection_adapters/beagle_turso/sync_job.rb
99
99
  - lib/active_record/connection_adapters/beagle_turso/sync_manager.rb
100
+ - lib/active_record/connection_adapters/beagle_turso/test_process_release.rb
100
101
  - lib/active_record/connection_adapters/beagle_turso_adapter.rb
101
102
  - lib/activerecord-beagle-turso.rb
102
103
  - lib/activerecord/beagle_turso/version.rb