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 +4 -4
- data/README.md +10 -0
- data/lib/active_record/connection_adapters/beagle_turso_adapter.rb +137 -34
- data/lib/activerecord/beagle_turso/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f368e18d7818a9897c2a59309e2850bb44ad977d97513ae210996d2762f138f7
|
|
4
|
+
data.tar.gz: ffe739174f2998da3bb0bba9de535687dc6143914c123db6df718e9fe2c361df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
115
|
-
@connection
|
|
116
|
-
@closed
|
|
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
|
-
# ---
|
|
185
|
-
# configure_connection
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
360
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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.
|
|
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.
|
|
40
|
+
version: '0.3'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: sqlite3
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|