From 958dbf41f3b8f00c18e2e01ae1c6c818e0738c1b Mon Sep 17 00:00:00 2001 From: Roman Sandler <5535625+sandlerr@users.noreply.github.com> Date: Mon, 26 Dec 2022 23:19:01 +1100 Subject: [PATCH 1/2] Initial Ractor support --- ext/sqlite3/extconf.rb | 3 ++ ext/sqlite3/sqlite3.c | 6 +++ lib/sqlite3.rb | 5 ++ sqlite3.gemspec | 1 + test/helper.rb | 1 + test/test_integration_ractor.rb | 87 +++++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+) create mode 100644 test/test_integration_ractor.rb diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index 733e22d5..9152f471 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -115,6 +115,9 @@ def configure_extension # Functions defined in 2.1 but not 2.0 have_func("rb_integer_pack") + # Functions defined in 3.0 but not 2.7 + have_func("rb_ext_ractor_safe") + # These functions may not be defined have_func("sqlite3_initialize") have_func("sqlite3_backup_init") diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index c0672a06..b7bdd729 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -91,6 +91,12 @@ init_sqlite3_constants(void) VALUE mSqlite3Constants; VALUE mSqlite3Open; +#ifdef HAVE_RB_EXT_RACTOR_SAFE + if (sqlite3_threadsafe()) { + rb_ext_ractor_safe(true); + } +#endif + mSqlite3Constants = rb_define_module_under(mSqlite3, "Constants"); /* sqlite3_open_v2 flags for Database::new */ diff --git a/lib/sqlite3.rb b/lib/sqlite3.rb index 790fd754..9fab21e7 100644 --- a/lib/sqlite3.rb +++ b/lib/sqlite3.rb @@ -14,4 +14,9 @@ module SQLite3 def self.threadsafe? threadsafe > 0 end + + # Is the gem's C extension marked as Ractor-safe? + def self.ractor_safe? + threadsafe? && !defined?(Ractor).nil? + end end diff --git a/sqlite3.gemspec b/sqlite3.gemspec index d0746454..183dcc2a 100644 --- a/sqlite3.gemspec +++ b/sqlite3.gemspec @@ -83,6 +83,7 @@ Gem::Specification.new do |s| "test/test_integration_aggregate.rb", "test/test_integration_open_close.rb", "test/test_integration_pending.rb", + "test/test_integration_ractor.rb", "test/test_integration_resultset.rb", "test/test_integration_statement.rb", "test/test_pragmas.rb", diff --git a/test/helper.rb b/test/helper.rb index 5ea6da11..930858b6 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -9,6 +9,7 @@ puts "info: sqlite version: #{SQLite3::SQLITE_VERSION}/#{SQLite3::SQLITE_LOADED_VERSION}" puts "info: sqlcipher?: #{SQLite3.sqlcipher?}" puts "info: threadsafe?: #{SQLite3.threadsafe?}" +puts "info: ractor_safe?: #{SQLite3.ractor_safe?}" module SQLite3 class TestCase < Minitest::Test diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb new file mode 100644 index 00000000..e058029d --- /dev/null +++ b/test/test_integration_ractor.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "helper" +require "fileutils" + +class IntegrationRactorTestCase < SQLite3::TestCase + STRESS_DB_NAME = "stress.db" + + def setup + teardown + end + + def teardown + FileUtils.rm_rf(Dir.glob("#{STRESS_DB_NAME}*")) + end + + def test_ractor_safe + skip unless RUBY_VERSION >= "3.0" && SQLite3.threadsafe? + assert_predicate SQLite3, :ractor_safe? + end + + def test_ractor_share_database + skip("Requires Ruby with Ractors") unless SQLite3.ractor_safe? + + db_receiver = Ractor.new do + db = Ractor.receive + Ractor.yield db.object_id + begin + db.execute("create table test_table ( b integer primary key)") + raise "Should have raised an exception in db.execute()" + rescue => e + Ractor.yield e + end + end + db_creator = Ractor.new(db_receiver) do |db_receiver| + db = SQLite3::Database.open(":memory:") + Ractor.yield db.object_id + db_receiver.send(db) + sleep 0.1 + db.execute("create table test_table ( a integer primary key)") + end + first_oid = db_creator.take + second_oid = db_receiver.take + assert_not_equal first_oid, second_oid + ex = db_receiver.take + # For now, let's assert that you can't pass database connections around + # between different Ractors. Letting a live DB connection exist in two + # threads that are running concurrently might expose us to footguns and + # lead to data corruption, so we should avoid this possibility and wait + # until connections can be given away using `yield` or `send`. + assert_equal "prepare called on a closed database", ex.message + end + + def test_ractor_stress + skip("Requires Ruby with Ractors") unless SQLite3.ractor_safe? + + # Testing with a file instead of :memory: since it can be more realistic + # compared with real production use, and so discover problems that in- + # memory testing won't find. Trivial example: STRESS_DB_NAME needs to be + # frozen to pass into the Ractor, but :memory: might avoid that problem by + # using a literal string. + db = SQLite3::Database.open(STRESS_DB_NAME) + db.execute("PRAGMA journal_mode=WAL") # A little slow without this + db.execute("create table stress_test (a integer primary_key, b text)") + random = Random.new.freeze + ractors = (0..9).map do |ractor_number| + Ractor.new(random, ractor_number) do |random, ractor_number| + db_in_ractor = SQLite3::Database.open(STRESS_DB_NAME) + db_in_ractor.busy_handler do + sleep random.rand / 100 # Lots of busy errors happen with multiple concurrent writers + true + end + 100.times do |i| + db_in_ractor.execute("insert into stress_test(a, b) values (#{ractor_number * 100 + i}, '#{random.rand}')") + end + end + end + ractors.each { |r| r.take } + final_check = Ractor.new do + db_in_ractor = SQLite3::Database.open(STRESS_DB_NAME) + res = db_in_ractor.execute("select count(*) from stress_test") + Ractor.yield res + end + res = final_check.take + assert_equal 1000, res[0][0] + end +end From 6ccb27b405f50787e7a1a0c487549fec589c2d05 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 10 Jan 2024 16:21:39 -0500 Subject: [PATCH 2/2] test: handle Ruby 3.3 behavior around passing unshareable objects --- test/test_integration_ractor.rb | 43 ++++++++++++++------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb index e058029d..f4f4d5d0 100644 --- a/test/test_integration_ractor.rb +++ b/test/test_integration_ractor.rb @@ -22,33 +22,26 @@ def test_ractor_safe def test_ractor_share_database skip("Requires Ruby with Ractors") unless SQLite3.ractor_safe? - db_receiver = Ractor.new do - db = Ractor.receive - Ractor.yield db.object_id - begin - db.execute("create table test_table ( b integer primary key)") - raise "Should have raised an exception in db.execute()" - rescue => e - Ractor.yield e + db = SQLite3::Database.open(":memory:") + + if RUBY_VERSION >= "3.3" + # after ruby/ruby@ce47ee00 + ractor = Ractor.new do + Ractor.receive end + + assert_raises(Ractor::Error) { ractor.send(db) } + else + # before ruby/ruby@ce47ee00 T_DATA objects could be copied + ractor = Ractor.new do + local_db = Ractor.receive + Ractor.yield local_db.object_id + end + ractor.send(db) + copy_id = ractor.take + + assert_not_equal db.object_id, copy_id end - db_creator = Ractor.new(db_receiver) do |db_receiver| - db = SQLite3::Database.open(":memory:") - Ractor.yield db.object_id - db_receiver.send(db) - sleep 0.1 - db.execute("create table test_table ( a integer primary key)") - end - first_oid = db_creator.take - second_oid = db_receiver.take - assert_not_equal first_oid, second_oid - ex = db_receiver.take - # For now, let's assert that you can't pass database connections around - # between different Ractors. Letting a live DB connection exist in two - # threads that are running concurrently might expose us to footguns and - # lead to data corruption, so we should avoid this possibility and wait - # until connections can be given away using `yield` or `send`. - assert_equal "prepare called on a closed database", ex.message end def test_ractor_stress