Skip to content

Commit 36b9ad0

Browse files
sandlerrflavorjones
authored andcommitted
Initial Ractor support
1 parent f7d1b5d commit 36b9ad0

File tree

7 files changed

+111
-1
lines changed

7 files changed

+111
-1
lines changed

ext/sqlite3/extconf.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ def configure_extension
115115
# Functions defined in 2.1 but not 2.0
116116
have_func("rb_integer_pack")
117117

118+
# Functions defined in 3.0 but not 2.7
119+
have_func("rb_ext_ractor_safe")
120+
118121
# These functions may not be defined
119122
have_func("sqlite3_initialize")
120123
have_func("sqlite3_backup_init")

ext/sqlite3/sqlite3.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ init_sqlite3_constants(void)
9191
VALUE mSqlite3Constants;
9292
VALUE mSqlite3Open;
9393

94+
#ifdef HAVE_RB_EXT_RACTOR_SAFE
95+
if (sqlite3_threadsafe()) {
96+
rb_ext_ractor_safe(true);
97+
}
98+
#endif
99+
94100
mSqlite3Constants = rb_define_module_under(mSqlite3, "Constants");
95101

96102
/* sqlite3_open_v2 flags for Database::new */

lib/sqlite3.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,9 @@ module SQLite3
1414
def self.threadsafe?
1515
threadsafe > 0
1616
end
17+
18+
# Is the gem's C extension marked as Ractor-safe?
19+
def self.ractor_safe?
20+
threadsafe? && !defined?(Ractor).nil?
21+
end
1722
end

lib/sqlite3/database.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,14 @@ def build_result_set stmt
759759

760760
private
761761

762-
NULL_TRANSLATOR = lambda { |_, row| row }
762+
# NULL_TRANSLATOR used to be a lambda, but a lambda can't be frozen (properly)
763+
# and so can't work with ractors.
764+
class NullTranslatorImplementation
765+
def self.call(_, row)
766+
row
767+
end
768+
end
769+
NULL_TRANSLATOR = NullTranslatorImplementation
763770

764771
def make_type_translator should_translate
765772
if should_translate

sqlite3.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Gem::Specification.new do |s|
8484
"test/test_integration_aggregate.rb",
8585
"test/test_integration_open_close.rb",
8686
"test/test_integration_pending.rb",
87+
"test/test_integration_ractor.rb",
8788
"test/test_integration_resultset.rb",
8889
"test/test_integration_statement.rb",
8990
"test/test_pragmas.rb",

test/helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
puts "info: sqlite version: #{SQLite3::SQLITE_VERSION}/#{SQLite3::SQLITE_LOADED_VERSION}"
1010
puts "info: sqlcipher?: #{SQLite3.sqlcipher?}"
1111
puts "info: threadsafe?: #{SQLite3.threadsafe?}"
12+
puts "info: ractor_safe?: #{SQLite3.ractor_safe?}"
1213

1314
module SQLite3
1415
class TestCase < Minitest::Test

test/test_integration_ractor.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
require "helper"
4+
require "fileutils"
5+
6+
class IntegrationRactorTestCase < SQLite3::TestCase
7+
STRESS_DB_NAME = "stress.db"
8+
9+
def setup
10+
teardown
11+
end
12+
13+
def teardown
14+
FileUtils.rm_rf(Dir.glob("#{STRESS_DB_NAME}*"))
15+
end
16+
17+
def test_ractor_safe
18+
skip unless RUBY_VERSION >= "3.0" && SQLite3.threadsafe?
19+
assert_predicate SQLite3, :ractor_safe?
20+
end
21+
22+
def test_ractor_share_database
23+
skip("Requires Ruby with Ractors") unless SQLite3.ractor_safe?
24+
25+
db_receiver = Ractor.new do
26+
db = Ractor.receive
27+
Ractor.yield db.object_id
28+
begin
29+
db.execute("create table test_table ( b integer primary key)")
30+
raise "Should have raised an exception in db.execute()"
31+
rescue => e
32+
Ractor.yield e
33+
end
34+
end
35+
db_creator = Ractor.new(db_receiver) do |db_receiver|
36+
db = SQLite3::Database.open(":memory:")
37+
Ractor.yield db.object_id
38+
db_receiver.send(db)
39+
sleep 0.1
40+
db.execute("create table test_table ( a integer primary key)")
41+
end
42+
first_oid = db_creator.take
43+
second_oid = db_receiver.take
44+
assert_not_equal first_oid, second_oid
45+
ex = db_receiver.take
46+
# For now, let's assert that you can't pass database connections around
47+
# between different Ractors. Letting a live DB connection exist in two
48+
# threads that are running concurrently might expose us to footguns and
49+
# lead to data corruption, so we should avoid this possibility and wait
50+
# until connections can be given away using `yield` or `send`.
51+
assert_equal "prepare called on a closed database", ex.message
52+
end
53+
54+
def test_ractor_stress
55+
skip("Requires Ruby with Ractors") unless SQLite3.ractor_safe?
56+
57+
# Testing with a file instead of :memory: since it can be more realistic
58+
# compared with real production use, and so discover problems that in-
59+
# memory testing won't find. Trivial example: STRESS_DB_NAME needs to be
60+
# frozen to pass into the Ractor, but :memory: might avoid that problem by
61+
# using a literal string.
62+
db = SQLite3::Database.open(STRESS_DB_NAME)
63+
db.execute("PRAGMA journal_mode=WAL") # A little slow without this
64+
db.execute("create table stress_test (a integer primary_key, b text)")
65+
random = Random.new.freeze
66+
ractors = (0..9).map do |ractor_number|
67+
Ractor.new(random, ractor_number) do |random, ractor_number|
68+
db_in_ractor = SQLite3::Database.open(STRESS_DB_NAME)
69+
db_in_ractor.busy_handler do
70+
sleep random.rand / 100 # Lots of busy errors happen with multiple concurrent writers
71+
true
72+
end
73+
100.times do |i|
74+
db_in_ractor.execute("insert into stress_test(a, b) values (#{ractor_number * 100 + i}, '#{random.rand}')")
75+
end
76+
end
77+
end
78+
ractors.each { |r| r.take }
79+
final_check = Ractor.new do
80+
db_in_ractor = SQLite3::Database.open(STRESS_DB_NAME)
81+
res = db_in_ractor.execute("select count(*) from stress_test")
82+
Ractor.yield res
83+
end
84+
res = final_check.take
85+
assert_equal 1000, res[0][0]
86+
end
87+
end

0 commit comments

Comments
 (0)