Skip to content

Commit e116ba5

Browse files
committed
Initial Ractor support
1 parent cc3fb2e commit e116ba5

File tree

7 files changed

+108
-1
lines changed

7 files changed

+108
-1
lines changed

ext/sqlite3/extconf.rb

+3
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ def configure_extension
108108
# Functions defined in 2.1 but not 2.0
109109
have_func('rb_integer_pack')
110110

111+
# Functions defined in 3.0 but not 2.7
112+
have_func('rb_ext_ractor_safe')
113+
111114
# These functions may not be defined
112115
have_func('sqlite3_initialize')
113116
have_func('sqlite3_backup_init')

ext/sqlite3/sqlite3.c

+5
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ static VALUE threadsafe_p(VALUE UNUSED(klass))
8484

8585
void init_sqlite3_constants()
8686
{
87+
#ifdef HAVE_RB_EXT_RACTOR_SAFE
88+
if (sqlite3_threadsafe()) {
89+
rb_ext_ractor_safe(true);
90+
}
91+
#endif
8792
VALUE mSqlite3Constants;
8893
VALUE mSqlite3Open;
8994

lib/sqlite3.rb

+3
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@
1212
module SQLite3
1313
# Was sqlite3 compiled with thread safety on?
1414
def self.threadsafe?; threadsafe > 0; end
15+
16+
# Is the gem's C extension marked as Ractor-safe?
17+
def self.ractor_safe?; threadsafe? && !defined?(Ractor).nil?; end
1518
end

lib/sqlite3/database.rb

+8-1
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,14 @@ def translate_from_db types, row
724724

725725
private
726726

727-
NULL_TRANSLATOR = lambda { |_, row| row }
727+
# NULL_TRANSLATOR used to be a lambda, but a lambda can't be frozen (properly)
728+
# and so can't work with ractors.
729+
class NullTranslatorImplementation
730+
def self.call(_, row)
731+
row
732+
end
733+
end
734+
NULL_TRANSLATOR = NullTranslatorImplementation
728735

729736
def make_type_translator should_translate
730737
if should_translate

sqlite3.gemspec

+1
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_result_set.rb",

test/helper.rb

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
puts "info: sqlite3 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
unless RUBY_VERSION >= "1.9"
1415
require 'iconv'

test/test_integration_ractor.rb

+87
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 TC_Integration_Ractor < 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 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)