Skip to content

Commit 36af661

Browse files
committed
Merge branch 'main' into sandlerr/ractor
* main: build(deps-dev): update rdoc requirement from 6.12.0 to 6.13.0 ci: get upstream sqlite-head job green build(deps-dev): update minitest requirement from 5.25.4 to 5.25.5 Add parameter checking for string value pragmas Simplify PRAGMA related constants Add some regression tests for setting pragmas Freeze strings and constants in pragmas
2 parents 2b0fff5 + 60acc0f commit 36af661

File tree

7 files changed

+217
-27
lines changed

7 files changed

+217
-27
lines changed

.github/workflows/upstream.yml

-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ jobs:
2525
with:
2626
ruby-version: "3.3"
2727
bundler-cache: true
28-
apt-get: tcl-dev # https://sqlite.org/forum/forumpost/45c4862d37
2928
- run: bundle exec rake compile -- --with-sqlite-source-dir=${GITHUB_WORKSPACE}/sqlite
3029
- run: bundle exec rake test
3130

Gemfile

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ source "https://rubygems.org"
33
gemspec
44

55
group :test do
6-
gem "minitest", "5.25.4"
6+
gem "minitest", "5.25.5"
77

88
gem "ruby_memcheck", "3.0.1" if Gem::Platform.local.os == "linux"
99

@@ -12,7 +12,7 @@ group :test do
1212
end
1313

1414
group :development do
15-
gem "rdoc", "6.12.0"
15+
gem "rdoc", "6.13.0"
1616

1717
gem "rubocop", "1.59.0", require: false
1818
gem "rubocop-minitest", "0.34.5", require: false

RACTOR.md

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Ractor support in SQLite3-Ruby
2+
3+
SQLite3 has [3 different modes of threading support](https://www.sqlite.org/threadsafe.html).
4+
5+
1. Single-thread
6+
2. Multi-thread
7+
3. Serialized
8+
9+
"Single thread" mode means there are no mutexes, so the library itself is not
10+
thread safe. In other words if two threads do `SQLite3::Database.new` on the
11+
same file, it will have thread safety problems.
12+
13+
"Multi thread" mode means that SQLite3 puts mutexes in place, but it does not
14+
mean that the SQLite3 API itself is thread safe. In other words, in this mode
15+
it is SAFE for two threads to do `SQLite3::Database.new` on the same file, but
16+
it is NOT SAFE to share that database object between two threads.
17+
18+
"Serialized" mode is like "Multi thread" mode except that there are mutexes in
19+
place such that it IS SAFE to share the same database object between threads.
20+
21+
## Ractor Safety
22+
23+
When a C extension claims to be Ractor safe by calling `rb_ext_ractor_safe`,
24+
it's merely claiming that it's C API is thread safe. This _does not_ mean that
25+
objects allocated from said C extension are allowed to cross between Ractor
26+
boundaries.
27+
28+
In other words, `rb_ext_ractor_safe` matches the expectations of the
29+
"multi-thread" mode of SQLite3. We can detect the multithread mode via the
30+
`sqlite3_threadsafe` function. In other words, it's fine to declare this
31+
extension is Ractor safe, but only if `sqlite3_threadsafe` returns true.
32+
33+
Even if we call `rb_ext_ractor_safe`, no database objects are allowed to be
34+
passed between Ractors. For example, this code will break with a Ractor error:
35+
36+
```ruby
37+
require "sqlite3"
38+
39+
r = Ractor.new {
40+
loop do
41+
break unless Ractor.receive
42+
end
43+
}
44+
45+
db = SQLite3::Database.new ":memory:"
46+
47+
begin
48+
r.send db
49+
puts "unreachable"
50+
rescue Ractor::Error
51+
end
52+
```
53+
54+
If the user opens the database in "Serialized" mode, then it _is_ OK to pass
55+
the database object between Ractors, or access the database in parallel because
56+
the SQLite API is fully thread-safe.
57+
58+
Passing the db connection is fine:
59+
60+
```ruby
61+
r = Ractor.new {
62+
loop do
63+
break unless Ractor.receive
64+
end
65+
}
66+
67+
db = SQLite3::Database.new ":memory:",
68+
flags: SQLite3::Constants::Open::FULLMUTEX |
69+
SQLite3::Constants::Open::READWRITE |
70+
SQLite3::Constants::Open::CREATE
71+
72+
# works
73+
r.send db
74+
```
75+
76+
Access the DB connection via global is fine:
77+
78+
```ruby
79+
require "sqlite3"
80+
81+
DB = SQLite3::Database.new ":memory:",
82+
flags: SQLite3::Constants::Open::FULLMUTEX |
83+
SQLite3::Constants::Open::READWRITE |
84+
SQLite3::Constants::Open::CREATE
85+
86+
r = Ractor.new {
87+
loop do
88+
Ractor.receive
89+
p DB
90+
end
91+
}
92+
93+
94+
r.send 123
95+
sleep
96+
```
97+
98+
## Fork Safety
99+
100+
Fork safety is restricted to database objects that were created on the main
101+
Ractor. When a process forks, the child process shuts down all Ractors, so
102+
any database connections that are inside a Ractor should be released.
103+
104+
However, this doesn't account for a situation where a child Ractor passes a
105+
database to the main Ractor:
106+
107+
```ruby
108+
require "sqlite3"
109+
110+
db = Ractor.new {
111+
SQLite3::Database.new ":memory:",
112+
flags: SQLite3::Constants::Open::FULLMUTEX |
113+
SQLite3::Constants::Open::READWRITE |
114+
SQLite3::Constants::Open::CREATE
115+
}.take
116+
117+
fork {
118+
# db wasn't tracked
119+
p db
120+
}
121+
```

ext/sqlite3/database.c

+1
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ rb_sqlite3_open_v2(VALUE self, VALUE file, VALUE mode, VALUE zvfs)
160160

161161
if (flags & SQLITE_OPEN_FULLMUTEX) {
162162
FL_SET_RAW(self, RUBY_FL_SHAREABLE);
163+
OBJ_FREEZE(self);
163164
}
164165

165166
return self;

lib/sqlite3/database.rb

+12-10
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ def quote(string)
141141
def initialize file, options = {}, zvfs = nil
142142
mode = Constants::Open::READWRITE | Constants::Open::CREATE
143143

144+
@tracefunc = nil
145+
@authorizer = nil
146+
@progress_handler = nil
147+
@collations = {}
148+
@functions = {}
149+
@results_as_hash = options[:results_as_hash]
150+
@readonly = false
151+
@default_transaction_mode = options[:default_transaction_mode] || :deferred
152+
144153
file = file.to_path if file.respond_to? :to_path
145154
if file.encoding == ::Encoding::UTF_16LE || file.encoding == ::Encoding::UTF_16BE || options[:utf16]
146155
open16 file
@@ -163,22 +172,15 @@ def initialize file, options = {}, zvfs = nil
163172
mode = options[:flags]
164173
end
165174

175+
@readonly = mode & Constants::Open::READONLY != 0
176+
166177
open_v2 file.encode("utf-8"), mode, zvfs
167178

168179
if options[:strict]
169180
disable_quirk_mode
170181
end
171182
end
172183

173-
@tracefunc = nil
174-
@authorizer = nil
175-
@progress_handler = nil
176-
@collations = {}
177-
@functions = {}
178-
@results_as_hash = options[:results_as_hash]
179-
@readonly = mode & Constants::Open::READONLY != 0
180-
@default_transaction_mode = options[:default_transaction_mode] || :deferred
181-
182184
initialize_extensions(options[:extensions])
183185

184186
ForkSafety.track(self) if Ractor.main?
@@ -196,7 +198,7 @@ def initialize file, options = {}, zvfs = nil
196198
#
197199
# Fetch the encoding set on this database
198200
def encoding
199-
prepare("PRAGMA encoding") { |stmt| Encoding.find(stmt.first.first) }
201+
Encoding.find super
200202
end
201203

202204
# Installs (or removes) a block that will be invoked for every access

lib/sqlite3/pragmas.rb

+63-14
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
require "sqlite3/errors"
24

35
module SQLite3
@@ -58,11 +60,20 @@ def get_enum_pragma(name)
5860
# have duplicate values. See #synchronous, #default_synchronous,
5961
# #temp_store, and #default_temp_store for usage examples.
6062
def set_enum_pragma(name, mode, enums)
61-
match = enums.find { |p| p.find { |i| i.to_s.downcase == mode.to_s.downcase } }
63+
match = if enums.is_a?(Array)
64+
# maybe deprecate this?
65+
enums.find { |p| p.find { |i| i.to_s.downcase == mode.to_s.downcase } }
66+
elsif mode.is_a?(String)
67+
enums.fetch(mode.downcase)
68+
else
69+
mode
70+
end
71+
6272
unless match
6373
raise SQLite3::Exception, "unrecognized #{name} #{mode.inspect}"
6474
end
65-
execute("PRAGMA #{name}='#{match.first.upcase}'")
75+
76+
execute("PRAGMA #{name}='#{match}'")
6677
end
6778

6879
# Returns the value of the given pragma as an integer.
@@ -77,26 +88,57 @@ def set_int_pragma(name, value)
7788
end
7889

7990
# The enumeration of valid synchronous modes.
80-
SYNCHRONOUS_MODES = [["full", 2], ["normal", 1], ["off", 0]]
91+
SYNCHRONOUS_MODES = {
92+
"full" => 2,
93+
"normal" => 1,
94+
"off" => 0
95+
}.freeze
8196

8297
# The enumeration of valid temp store modes.
83-
TEMP_STORE_MODES = [["default", 0], ["file", 1], ["memory", 2]]
98+
TEMP_STORE_MODES = {
99+
"default" => 0,
100+
"file" => 1,
101+
"memory" => 2
102+
}.freeze
84103

85104
# The enumeration of valid auto vacuum modes.
86-
AUTO_VACUUM_MODES = [["none", 0], ["full", 1], ["incremental", 2]]
105+
AUTO_VACUUM_MODES = {
106+
"none" => 0,
107+
"full" => 1,
108+
"incremental" => 2
109+
}.freeze
87110

88111
# The list of valid journaling modes.
89-
JOURNAL_MODES = [["delete"], ["truncate"], ["persist"], ["memory"],
90-
["wal"], ["off"]]
112+
JOURNAL_MODES = {
113+
"delete" => "delete",
114+
"truncate" => "truncate",
115+
"persist" => "persist",
116+
"memory" => "memory",
117+
"wal" => "wal",
118+
"off" => "off"
119+
}.freeze
91120

92121
# The list of valid locking modes.
93-
LOCKING_MODES = [["normal"], ["exclusive"]]
122+
LOCKING_MODES = {
123+
"normal" => "normal",
124+
"exclusive" => "exclusive"
125+
}.freeze
94126

95127
# The list of valid encodings.
96-
ENCODINGS = [["utf-8"], ["utf-16"], ["utf-16le"], ["utf-16be"]]
128+
ENCODINGS = {
129+
"utf-8" => "utf-8",
130+
"utf-16" => "utf-16",
131+
"utf-16le" => "utf-16le",
132+
"utf-16be" => "utf-16be"
133+
}.freeze
97134

98135
# The list of valid WAL checkpoints.
99-
WAL_CHECKPOINTS = [["passive"], ["full"], ["restart"], ["truncate"]]
136+
WAL_CHECKPOINTS = {
137+
"passive" => "passive",
138+
"full" => "full",
139+
"restart" => "restart",
140+
"truncate" => "truncate"
141+
}.freeze
100142

101143
def application_id
102144
get_int_pragma "application_id"
@@ -227,7 +269,7 @@ def encoding
227269
end
228270

229271
def encoding=(mode)
230-
set_enum_pragma "encoding", mode, ENCODINGS
272+
set_string_pragma "encoding", mode, ENCODINGS
231273
end
232274

233275
def foreign_key_check(*table, &block) # :yields: row
@@ -295,7 +337,7 @@ def journal_mode
295337
end
296338

297339
def journal_mode=(mode)
298-
set_enum_pragma "journal_mode", mode, JOURNAL_MODES
340+
set_string_pragma "journal_mode", mode, JOURNAL_MODES
299341
end
300342

301343
def journal_size_limit
@@ -319,7 +361,7 @@ def locking_mode
319361
end
320362

321363
def locking_mode=(mode)
322-
set_enum_pragma "locking_mode", mode, LOCKING_MODES
364+
set_string_pragma "locking_mode", mode, LOCKING_MODES
323365
end
324366

325367
def max_page_count
@@ -525,7 +567,7 @@ def wal_checkpoint
525567
end
526568

527569
def wal_checkpoint=(mode)
528-
set_enum_pragma "wal_checkpoint", mode, WAL_CHECKPOINTS
570+
set_string_pragma "wal_checkpoint", mode, WAL_CHECKPOINTS
529571
end
530572

531573
def writable_schema=(mode)
@@ -568,6 +610,13 @@ def table_info table
568610

569611
private
570612

613+
def set_string_pragma(pragma_name, value, valid_values)
614+
valid_values.fetch(value.to_s.downcase) {
615+
raise SQLite3::Exception, "unrecognized #{pragma_name} #{value.inspect}"
616+
}
617+
set_enum_pragma(pragma_name, value, valid_values)
618+
end
619+
571620
# Compares two version strings
572621
def version_compare(v1, v2)
573622
v1 = v1.split(".").map { |i| i.to_i }

test/test_pragmas.rb

+18
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ def teardown
2727
@db.close
2828
end
2929

30+
def test_temp_store_mode
31+
@db.temp_store = "memory"
32+
assert_equal 2, @db.temp_store
33+
@db.temp_store = 1
34+
assert_equal 1, @db.temp_store
35+
end
36+
37+
def test_encoding
38+
@db.encoding = "utf-16le"
39+
assert_equal Encoding.find("utf-16le"), @db.encoding
40+
end
41+
3042
def test_pragma_errors
3143
assert_raises(SQLite3::Exception) do
3244
@db.set_enum_pragma("foo", "bar", [])
@@ -41,6 +53,12 @@ def test_pragma_errors
4153
end
4254
end
4355

56+
def test_invalid_pragma
57+
assert_raises(SQLite3::Exception) do
58+
@db.journal_mode = 0
59+
end
60+
end
61+
4462
def test_get_boolean_pragma
4563
refute(@db.get_boolean_pragma("read_uncommitted"))
4664
end

0 commit comments

Comments
 (0)