activerecord-beagle-turso 0.1.7 → 0.1.9

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: 51cf88616ac49583385f9d993560cc4f5fb53169707dec16c68b3c37f6d60884
4
- data.tar.gz: 21732cc614964877aeca0da6e34d066cfcfb2061747ac68f9eb2803abff60d1a
3
+ metadata.gz: f368e18d7818a9897c2a59309e2850bb44ad977d97513ae210996d2762f138f7
4
+ data.tar.gz: ffe739174f2998da3bb0bba9de535687dc6143914c123db6df718e9fe2c361df
5
5
  SHA512:
6
- metadata.gz: fe0832ec4ae1acc466934cb0ed1a0c47f2de5e5d5f86b47091ffbe1c5e8f77c86ee50534808d2cf7dbef318aa526cd0026146ac367d1c3f56275723d8630ccc3
7
- data.tar.gz: bf86e7af654df142cd722228c9245bd5456b6cfd9b2de5a43ae9067e018fee5491e17005896eec7f693ef0fc9e8d9bd9ac1d5791033bfff579879562fd69d1b9
6
+ metadata.gz: '07097b798e8ef32bfad3d84318c9b1101284631a7e8171006c1a1be788e12d89e4dc292a7ef2155fdcc3468a1730046f4629c2c7fb16cb5bbc69b7d27965ed50'
7
+ data.tar.gz: 61880c51d328741a0cbf7641bb2e9eb12b946db91f4472265870b5e03b22cd4bc2ae07023381185bea42afc5329777ab83d31f0e4bab553267ee783df070d188
data/README.md CHANGED
@@ -63,6 +63,16 @@ some diagnostics, but these are custom `database.yml` keys, so that masking
63
63
  does not cover them automatically. Source them from encrypted credentials or
64
64
  `ENV`, as above, and avoid logging/inspecting the raw connection config.
65
65
 
66
+ `timeout:` (milliseconds, as in the stock SQLite adapter; Rails' generated
67
+ `database.yml` sets 5000) is how long a write waits for another connection's
68
+ transaction to finish before raising `Beagle::Turso::BusyError`. Transactions
69
+ begin with `BEGIN IMMEDIATE`, so a writer waits at `BEGIN`, never part-way
70
+ through. The wait sleeps in Ruby, so other threads and fibers keep running.
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.
75
+
66
76
  An optional `bootstrap_if_empty:` (default `true`) controls whether a brand
67
77
  new/empty local file is populated from the remote on first open — see
68
78
  `Beagle::Turso::Database.open`.
@@ -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
@@ -111,9 +181,10 @@ module ActiveRecord
111
181
  attr_reader :database, :connection
112
182
 
113
183
  def initialize(database)
114
- @database = database
115
- @connection = database.connect
116
- @closed = false
184
+ @database = database
185
+ @connection = database.connect
186
+ @closed = false
187
+ @busy_timeout = nil
117
188
  end
118
189
 
119
190
  # A bare read of exactly +defer_foreign_keys+ or +read_uncommitted+
@@ -181,11 +252,22 @@ module ActiveRecord
181
252
  def push = database.push
182
253
  def pull = database.pull
183
254
 
184
- # --- sqlite3-gem lifecycle shims the SQLite3Adapter drives ---
185
- # configure_connection only touches this when config[:timeout] is set.
186
- def busy_handler_timeout=(_)
187
- nil
255
+ # --- busy handling ---
256
+ # configure_connection passes database.yml's `timeout:` (ms) here, as it
257
+ # does to the sqlite3 gem's busy handler. Unset or 0 means no waiting:
258
+ # a BusyError surfaces at once, as with the sqlite3 gem.
259
+ def busy_handler_timeout=(milliseconds)
260
+ @busy_timeout = BeagleTursoAdapter.busy_timeout(milliseconds)
188
261
  end
262
+
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, &)
268
+ end
269
+
270
+ # --- sqlite3-gem lifecycle shims the SQLite3Adapter drives ---
189
271
  # connected? => !(@raw_connection.nil? || @raw_connection.closed?)
190
272
  def closed? = @closed
191
273
  # Release the beagle-turso session eagerly rather than waiting for GC.
@@ -356,26 +438,10 @@ module ActiveRecord
356
438
  if batch
357
439
  raw_connection.execute_batch(sql)
358
440
  ::ActiveRecord::Result.empty
359
- elsif write_statement?(sql)
360
- if (rewritten = sql_with_returning_rowid(sql))
361
- insert_returning_rowid(raw_connection, rewritten, params)
362
- else
363
- affected = raw_connection.execute(sql, params)
364
- @last_inserted_rowid = raw_connection.last_insert_rowid
365
- ::ActiveRecord::Result.empty(affected_rows: affected)
366
- end
441
+ elsif busy_retry?(sql)
442
+ raw_connection.retry_busy { run_statement(raw_connection, sql, params) }
367
443
  else
368
- columns, rows = raw_connection.query_result(sql, params)
369
- # Belt-and-suspenders: a RETURNING insert lands here (id read from
370
- # rows by last_inserted_id), and a data-modifying CTE that slips past
371
- # write_statement? would too -- keep the rowid so an inserted id is
372
- # never silently lost even on a heuristic miss.
373
- if INSERT_REGEX.match?(sql)
374
- # On a replica where last_insert_rowid is untrustworthy, never
375
- # offer it as a fallback id (a RETURNING insert's id is in rows).
376
- @last_inserted_rowid = insert_returning_rowid? ? nil : raw_connection.last_insert_rowid
377
- end
378
- ::ActiveRecord::Result.new(columns, rows)
444
+ run_statement(raw_connection, sql, params)
379
445
  end
380
446
 
381
447
  verified!
@@ -384,6 +450,43 @@ module ActiveRecord
384
450
  result
385
451
  end
386
452
 
453
+ # A busy statement is retried only when that is safe: the BEGIN that
454
+ # opens a transaction (Rails sends BEGIN IMMEDIATE, so a writer waits
455
+ # for the lock there rather than failing mid-way), or a statement
456
+ # outside any transaction. Inside one, a BusyError means the
457
+ # transaction is dead; it must surface and roll back. A multi-statement
458
+ # batch is never retried (it may have run in part).
459
+ BEGIN_REGEX = /\A\s*BEGIN\b/i
460
+ private_constant :BEGIN_REGEX
461
+
462
+ def busy_retry?(sql)
463
+ !transaction_open? || BEGIN_REGEX.match?(mask_non_code(sql))
464
+ end
465
+
466
+ def run_statement(raw_connection, sql, params)
467
+ if write_statement?(sql)
468
+ if (rewritten = sql_with_returning_rowid(sql))
469
+ insert_returning_rowid(raw_connection, rewritten, params)
470
+ else
471
+ affected = raw_connection.execute(sql, params)
472
+ @last_inserted_rowid = raw_connection.last_insert_rowid
473
+ ::ActiveRecord::Result.empty(affected_rows: affected)
474
+ end
475
+ else
476
+ columns, rows = raw_connection.query_result(sql, params)
477
+ # Belt-and-suspenders: a RETURNING insert lands here (id read from
478
+ # rows by last_inserted_id), and a data-modifying CTE that slips past
479
+ # write_statement? would too -- keep the rowid so an inserted id is
480
+ # never silently lost even on a heuristic miss.
481
+ if INSERT_REGEX.match?(sql)
482
+ # On a replica where last_insert_rowid is untrustworthy, never
483
+ # offer it as a fallback id (a RETURNING insert's id is in rows).
484
+ @last_inserted_rowid = insert_returning_rowid? ? nil : raw_connection.last_insert_rowid
485
+ end
486
+ ::ActiveRecord::Result.new(columns, rows)
487
+ end
488
+ end
489
+
387
490
  # Route to the WRITE branch (execute -> affected count + last_insert_rowid)
388
491
  # rather than the read branch. A statement is a write when SQLite3's own
389
492
  # read/write classifier says so, OR when it is a data-modifying CTE that
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module BeagleTurso
5
- VERSION = "0.1.7"
5
+ VERSION = "0.1.9"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-beagle-turso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - BeagleSoftwareUK
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.2'
33
+ version: '0.3'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.2'
40
+ version: '0.3'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: sqlite3
43
43
  requirement: !ruby/object:Gem::Requirement