activerecord-libsql 0.1.7 → 0.1.8

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: c7d43524e4ffab6a8d48512dae7494bd422a0484f4d5de4aab9638af9cc05e11
4
- data.tar.gz: 19c8598dd1fe21df3a71822adfbc03b0f99b01a50d46ec0544a5c9ed54fafd76
3
+ metadata.gz: f3e95b3b3a0f542585ae7ede19f84e2facf3e87e4499248b71f3b5cdb80ae3c0
4
+ data.tar.gz: da7a0f17f5cf1e7f68d7b3fc6b8d8a777a230896893f8bf3c17e915d68ff515e
5
5
  SHA512:
6
- metadata.gz: ec59f0d00947a93fc541d2fbe27bbef5132208a472199ad1869187e85beb0a936d49258f3330be290ede8c01f15c3c0eae851d8518725ba2abe2d64a1d38e1a0
7
- data.tar.gz: 2823e8e01b17e6553a9ae4f676d589f9e11349200bd133a0e7a20bd372480e5d46efa8098afb6b9780146efb1e5d82722dfa120400d8345c0fd5c1cd8a2c6e77
6
+ metadata.gz: bbe4d99c6b76a98b58991af803f015db99c6aa41d2d997366d113fa9d0159cd1a55206eaab719cb9c2cc16d57f17c8ff7c8422a910b69e6f15efcc8ce656a51f
7
+ data.tar.gz: 8c8a30e54eea83a936dc2291d8604cbf3d67bd7b9ec8fec508b61d0b39dfaf999fa9ea0cde407dcab9048b5ca501e57d2e5f5afec531e64dcca1b1e561d9f447
data/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.8] - 2026-03-30
6
+
7
+ ### Fixed
8
+
9
+ - **`SQLite3::BusyException: database is locked` when Solid Queue forks multiple workers** (#19)
10
+ - `LocalConnection` (used when `replica_path:` is configured) opened SQLite with the default
11
+ DELETE journal mode, which serializes all writes. When Solid Queue forks multiple worker
12
+ processes that all write to the same `.sqlite3` file concurrently, lock contention caused
13
+ an immediate `BusyException`.
14
+ - `LocalConnection#initialize` now sets `PRAGMA journal_mode=WAL` (allows concurrent reads
15
+ and writes across processes) and `PRAGMA busy_timeout=5000` (waits up to 5 seconds on
16
+ lock contention instead of failing immediately).
17
+
18
+ ### Added
19
+
20
+ - **`rake turso:check` task** (#19)
21
+ - Verifies adapter health for all databases configured in `database.yml`.
22
+ - Checks: connection (`SELECT 1`), INSERT, SELECT, `datetime` UTC comparison,
23
+ and transaction (`BEGIN` / `COMMIT` / `ROLLBACK`).
24
+ - Useful after initial installation or when migrating an existing app to Turso.
25
+ - **Embedded Replica mode tests in `solid_queue_fork_spec.rb`** (#19)
26
+ - 4 new examples covering `LocalConnection` with Solid Queue fork patterns.
27
+ - Directly reproduces the `database is locked` scenario (5 concurrent forked workers
28
+ writing to the same SQLite file).
29
+ - Verifies WAL mode and `busy_timeout` are set on the internal connection.
30
+
31
+ ### Fixed (infrastructure)
32
+
33
+ - `activerecord-libsql.gemspec`: added `lib/**/*.rake` to `spec.files` so that
34
+ `lib/tasks/turso.rake` is included in the published gem.
35
+ - `lib/activerecord/libsql/railtie.rb`: fixed rake file load path
36
+ (`../../tasks/turso.rake` instead of `../../../tasks/turso.rake`).
37
+
5
38
  ## [0.1.7] - 2026-03-27
6
39
 
7
40
  ### Fixed
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.files = Dir[
26
26
  'lib/**/*.rb',
27
+ 'lib/**/*.rake',
27
28
  '*.md',
28
29
  '*.gemspec'
29
30
  ]
@@ -6,7 +6,7 @@ module ActiveRecord
6
6
  module Libsql
7
7
  class Railtie < Rails::Railtie
8
8
  rake_tasks do
9
- load File.expand_path('../../../tasks/turso.rake', __dir__)
9
+ load File.expand_path('../../tasks/turso.rake', __dir__)
10
10
  end
11
11
  end
12
12
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Libsql
5
- VERSION = '0.1.7'
5
+ VERSION = '0.1.8'
6
6
  end
7
7
  end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'tempfile'
5
+ require 'fileutils'
6
+
7
+ namespace :turso do
8
+ desc <<~DESC
9
+ Check Turso connection and adapter health for all configured databases.
10
+
11
+ Verifies:
12
+ 1. Connection (SELECT 1)
13
+ 2. Basic write/read (INSERT / SELECT / DELETE)
14
+ 3. datetime UTC normalization (WHERE datetime <= ?)
15
+ 4. Transaction (BEGIN / COMMIT / ROLLBACK)
16
+
17
+ Usage:
18
+ rake turso:check
19
+ DESC
20
+ task check: :environment do
21
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
22
+
23
+ if configs.empty?
24
+ puts "No databases configured for environment: #{Rails.env}"
25
+ exit 1
26
+ end
27
+
28
+ all_ok = true
29
+
30
+ configs.each do |db_config|
31
+ name = db_config.name
32
+ print " [#{name}] "
33
+
34
+ begin
35
+ pool = ActiveRecord::Base.establish_connection(db_config.configuration_hash)
36
+ pool.with_connection do |conn|
37
+ # 1. 接続確認
38
+ conn.execute('SELECT 1')
39
+ print '✓ connect '
40
+
41
+ # 2. 一時テーブルで write/read を確認
42
+ table = "_turso_check_#{SecureRandom.hex(4)}"
43
+ begin
44
+ conn.create_table(table, force: true) do |t|
45
+ t.string :label, null: false
46
+ t.datetime :checked_at, null: false
47
+ end
48
+
49
+ # INSERT
50
+ now = Time.now.utc
51
+ conn.execute(
52
+ "INSERT INTO #{table} (label, checked_at) VALUES ('ping', '#{now.strftime('%Y-%m-%d %H:%M:%S')}')"
53
+ )
54
+ print '✓ insert '
55
+
56
+ # SELECT
57
+ rows = conn.execute("SELECT label FROM #{table} WHERE label = 'ping'")
58
+ raise 'SELECT returned no rows' if rows.empty?
59
+
60
+ print '✓ select '
61
+
62
+ # datetime 比較(UTC 正規化の確認)
63
+ future = (now + 60).strftime('%Y-%m-%d %H:%M:%S')
64
+ rows = conn.execute("SELECT label FROM #{table} WHERE checked_at <= '#{future}'")
65
+ raise 'datetime comparison failed' if rows.empty?
66
+
67
+ print '✓ datetime '
68
+
69
+ # トランザクション
70
+ conn.transaction do
71
+ conn.execute("INSERT INTO #{table} (label, checked_at) VALUES ('txn', '#{now.strftime('%Y-%m-%d %H:%M:%S')}')")
72
+ end
73
+ print '✓ transaction '
74
+ ensure
75
+ conn.drop_table(table, if_exists: true)
76
+ end
77
+ end
78
+
79
+ puts '→ OK'
80
+ rescue StandardError => e
81
+ puts "→ FAILED: #{e.message}"
82
+ all_ok = false
83
+ ensure
84
+ # primary に戻す
85
+ primary_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'primary')
86
+ ActiveRecord::Base.establish_connection(primary_config.configuration_hash) if primary_config
87
+ end
88
+ end
89
+
90
+ exit 1 unless all_ok
91
+ end
92
+
93
+ namespace :schema do
94
+ desc <<~DESC
95
+ Apply schema to Turso Cloud using sqldef (sqlite3def).
96
+
97
+ Compares the desired schema (schema.sql) against the current remote schema
98
+ and applies only the diff — idempotent and declarative.
99
+
100
+ Usage:
101
+ rake turso:schema:apply[db/schema.sql]
102
+
103
+ Prerequisites:
104
+ - sqlite3def must be installed (https://github.com/sqldef/sqldef)
105
+ - database.yml must have replica_path configured
106
+
107
+ Flow:
108
+ 1. Pull latest frames from remote into local replica
109
+ 2. Copy replica to a temp file for sqlite3def (avoids libsql metadata conflict)
110
+ 3. Run sqlite3def --dry-run to compute diff SQL
111
+ 4. If no diff, exit normally ("Already up to date")
112
+ 5. Apply diff SQL to Turso Cloud
113
+ 6. Pull again to confirm
114
+ DESC
115
+ task :apply, [:schema_file] => :environment do |_t, args|
116
+ schema_file = args[:schema_file]
117
+ abort 'Usage: rake turso:schema:apply[path/to/schema.sql]' unless schema_file
118
+ abort "Schema file not found: #{schema_file}" unless File.exist?(schema_file)
119
+
120
+ unless system('which sqlite3def > /dev/null 2>&1')
121
+ abort 'sqlite3def not found. Install it from https://github.com/sqldef/sqldef'
122
+ end
123
+
124
+ conn = ActiveRecord::Base.connection
125
+ replica_path = conn.instance_variable_get(:@config)&.dig(:replica_path)
126
+ abort 'replica_path is not configured in database.yml' unless replica_path
127
+ abort "Replica file not found: #{replica_path} (run the app first to initialize)" \
128
+ unless File.exist?(replica_path)
129
+
130
+ puts '==> [1/4] Pulling latest schema from remote...'
131
+ conn.sync
132
+ puts ' Done.'
133
+
134
+ # sqlite3def は SQLite ファイルを直接開くため、libsql の replica と競合しないよう
135
+ # 一時ファイルにコピーして使う。WAL モードの場合は -wal / -shm も一緒にコピーする。
136
+ tmp_db = Tempfile.new(['turso_schema_diff', '.db'])
137
+ tmp_db.close
138
+ begin
139
+ FileUtils.cp(replica_path, tmp_db.path)
140
+ %w[-wal -shm].each do |suffix|
141
+ src = "#{replica_path}#{suffix}"
142
+ FileUtils.cp(src, "#{tmp_db.path}#{suffix}") if File.exist?(src)
143
+ end
144
+
145
+ puts '==> [2/4] Computing schema diff...'
146
+ diff_sql = `sqlite3def --dry-run --file #{Shellwords.escape(schema_file)} #{Shellwords.escape(tmp_db.path)} 2>&1`
147
+ exit_status = $?.exitstatus
148
+
149
+ abort "sqlite3def failed (exit #{exit_status}):\n#{diff_sql}" if exit_status != 0
150
+ ensure
151
+ tmp_db.unlink
152
+ FileUtils.rm_f("#{tmp_db.path}-wal")
153
+ FileUtils.rm_f("#{tmp_db.path}-shm")
154
+ end
155
+
156
+ # BEGIN / COMMIT / コメント行(--)を除外する
157
+ statements = diff_sql
158
+ .split(';')
159
+ .map(&:strip)
160
+ .reject(&:empty?)
161
+ .reject { |s| s.match?(/\A(BEGIN|COMMIT|ROLLBACK)\z/i) }
162
+ .reject { |s| s.match?(/\A--/) }
163
+
164
+ if statements.empty?
165
+ puts ' Already up to date.'
166
+ next
167
+ end
168
+
169
+ puts " #{statements.size} statement(s) to apply:"
170
+ statements.each { |s| puts " #{s.lines.first&.strip}" }
171
+
172
+ puts '==> [3/4] Applying schema to Turso Cloud...'
173
+ # libsql は DDL トランザクションをサポートしないため、直接実行する
174
+ statements.each { |sql| conn.execute(sql) }
175
+ puts ' Done.'
176
+
177
+ puts '==> [4/4] Pulling to confirm...'
178
+ conn.sync
179
+ puts ' Done.'
180
+
181
+ puts '==> Schema applied successfully!'
182
+ end
183
+
184
+ desc 'Show schema diff between schema.sql and Turso Cloud (dry-run, no changes applied)'
185
+ task :diff, [:schema_file] => :environment do |_t, args|
186
+ schema_file = args[:schema_file]
187
+ abort 'Usage: rake turso:schema:diff[path/to/schema.sql]' unless schema_file
188
+ abort "Schema file not found: #{schema_file}" unless File.exist?(schema_file)
189
+
190
+ unless system('which sqlite3def > /dev/null 2>&1')
191
+ abort 'sqlite3def not found. Install it from https://github.com/sqldef/sqldef'
192
+ end
193
+
194
+ conn = ActiveRecord::Base.connection
195
+ replica_path = conn.instance_variable_get(:@config)&.dig(:replica_path)
196
+ abort 'replica_path is not configured in database.yml' unless replica_path
197
+ abort "Replica file not found: #{replica_path}" unless File.exist?(replica_path)
198
+
199
+ puts '==> Pulling latest schema from remote...'
200
+ conn.sync
201
+
202
+ tmp_db = Tempfile.new(['turso_schema_diff', '.db'])
203
+ tmp_db.close
204
+ begin
205
+ FileUtils.cp(replica_path, tmp_db.path)
206
+ %w[-wal -shm].each do |suffix|
207
+ src = "#{replica_path}#{suffix}"
208
+ FileUtils.cp(src, "#{tmp_db.path}#{suffix}") if File.exist?(src)
209
+ end
210
+
211
+ puts '==> Schema diff (sqlite3def dry-run):'
212
+ diff_sql = `sqlite3def --dry-run --file #{Shellwords.escape(schema_file)} #{Shellwords.escape(tmp_db.path)} 2>&1`
213
+
214
+ statements = diff_sql
215
+ .split(';')
216
+ .map(&:strip)
217
+ .reject(&:empty?)
218
+ .reject { |s| s.match?(/\A(BEGIN|COMMIT|ROLLBACK)\z/i) }
219
+ .reject { |s| s.match?(/\A--/) }
220
+
221
+ if statements.empty?
222
+ puts ' No changes. Already up to date.'
223
+ else
224
+ puts diff_sql
225
+ end
226
+ ensure
227
+ tmp_db.unlink
228
+ FileUtils.rm_f("#{tmp_db.path}-wal")
229
+ FileUtils.rm_f("#{tmp_db.path}-shm")
230
+ end
231
+ end
232
+ end
233
+ end
@@ -110,6 +110,10 @@ module TursoLibsql
110
110
  # AR の discard! が @raw_connection を nil にするので、子プロセスでは
111
111
  # reconnect が走って新しい接続が確立される。
112
112
  class LocalConnection
113
+ # Solid Queue など複数プロセスが同時に書き込む場合のロック待機時間(ミリ秒)。
114
+ # デフォルトの 0ms だと即 SQLite3::BusyException になる。
115
+ BUSY_TIMEOUT_MS = 5000
116
+
113
117
  def initialize(path, remote_url, token, mode)
114
118
  require 'sqlite3'
115
119
  @path = path
@@ -118,6 +122,12 @@ module TursoLibsql
118
122
  @mode = mode
119
123
  @db = SQLite3::Database.new(path)
120
124
  @db.results_as_hash = true
125
+ # WAL モード: 読み取りと書き込みを並行できる。
126
+ # デフォルトの DELETE ジャーナルモードは同時書き込みで database is locked になる。
127
+ # Solid Queue のように複数 fork が同じファイルに書く場合に必須。
128
+ @db.execute('PRAGMA journal_mode=WAL')
129
+ # ロック競合時に即エラーにならず、指定ミリ秒待ってリトライする。
130
+ @db.execute("PRAGMA busy_timeout=#{BUSY_TIMEOUT_MS}")
121
131
  @last_insert_rowid = 0
122
132
  @last_affected_rows = 0
123
133
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-libsql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - aileron
@@ -96,6 +96,7 @@ files:
96
96
  - lib/activerecord-libsql.rb
97
97
  - lib/activerecord/libsql/railtie.rb
98
98
  - lib/activerecord/libsql/version.rb
99
+ - lib/tasks/turso.rake
99
100
  - lib/turso_libsql.rb
100
101
  - lib/turso_libsql/connection.rb
101
102
  - lib/turso_libsql/database.rb