diff --git a/RACTOR.md b/RACTOR.md new file mode 100644 index 00000000..55700df5 --- /dev/null +++ b/RACTOR.md @@ -0,0 +1,121 @@ +# Ractor support in SQLite3-Ruby + +SQLite3 has [3 different modes of threading support](https://www.sqlite.org/threadsafe.html). + +1. Single-thread +2. Multi-thread +3. Serialized + +"Single thread" mode means there are no mutexes, so the library itself is not +thread safe. In other words if two threads do `SQLite3::Database.new` on the +same file, it will have thread safety problems. + +"Multi thread" mode means that SQLite3 puts mutexes in place, but it does not +mean that the SQLite3 API itself is thread safe. In other words, in this mode +it is SAFE for two threads to do `SQLite3::Database.new` on the same file, but +it is NOT SAFE to share that database object between two threads. + +"Serialized" mode is like "Multi thread" mode except that there are mutexes in +place such that it IS SAFE to share the same database object between threads. + +## Ractor Safety + +When a C extension claims to be Ractor safe by calling `rb_ext_ractor_safe`, +it's merely claiming that it's C API is thread safe. This _does not_ mean that +objects allocated from said C extension are allowed to cross between Ractor +boundaries. + +In other words, `rb_ext_ractor_safe` matches the expectations of the +"multi-thread" mode of SQLite3. We can detect the multithread mode via the +`sqlite3_threadsafe` function. In other words, it's fine to declare this +extension is Ractor safe, but only if `sqlite3_threadsafe` returns true. + +Even if we call `rb_ext_ractor_safe`, no database objects are allowed to be +passed between Ractors. For example, this code will break with a Ractor error: + +```ruby +require "sqlite3" + +r = Ractor.new { + loop do + break unless Ractor.receive + end +} + +db = SQLite3::Database.new ":memory:" + +begin + r.send db + puts "unreachable" +rescue Ractor::Error +end +``` + +If the user opens the database in "Serialized" mode, then it _is_ OK to pass +the database object between Ractors, or access the database in parallel because +the SQLite API is fully thread-safe. + +Passing the db connection is fine: + +```ruby +r = Ractor.new { + loop do + break unless Ractor.receive + end +} + +db = SQLite3::Database.new ":memory:", + flags: SQLite3::Constants::Open::FULLMUTEX | + SQLite3::Constants::Open::READWRITE | + SQLite3::Constants::Open::CREATE + +# works +r.send db +``` + +Access the DB connection via global is fine: + +```ruby +require "sqlite3" + +DB = SQLite3::Database.new ":memory:", + flags: SQLite3::Constants::Open::FULLMUTEX | + SQLite3::Constants::Open::READWRITE | + SQLite3::Constants::Open::CREATE + +r = Ractor.new { + loop do + Ractor.receive + p DB + end +} + + +r.send 123 +sleep +``` + +## Fork Safety + +Fork safety is restricted to database objects that were created on the main +Ractor. When a process forks, the child process shuts down all Ractors, so +any database connections that are inside a Ractor should be released. + +However, this doesn't account for a situation where a child Ractor passes a +database to the main Ractor: + +```ruby +require "sqlite3" + +db = Ractor.new { + SQLite3::Database.new ":memory:", + flags: SQLite3::Constants::Open::FULLMUTEX | + SQLite3::Constants::Open::READWRITE | + SQLite3::Constants::Open::CREATE +}.take + +fork { + # db wasn't tracked + p db +} +``` diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index a35ff3a1..33575512 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -158,6 +158,11 @@ rb_sqlite3_open_v2(VALUE self, VALUE file, VALUE mode, VALUE zvfs) ctx->flags |= SQLITE3_RB_DATABASE_READONLY; } + if (flags & SQLITE_OPEN_FULLMUTEX) { + FL_SET_RAW(self, RUBY_FL_SHAREABLE); + OBJ_FREEZE(self); + } + return self; } diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index 7111c1d0..6d3f4a80 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -125,6 +125,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 d30e85d5..06e439e9 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -127,6 +127,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 93caef14..837c936f 100644 --- a/lib/sqlite3.rb +++ b/lib/sqlite3.rb @@ -14,6 +14,11 @@ 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 require "sqlite3/version_info" diff --git a/lib/sqlite3/database.rb b/lib/sqlite3/database.rb index 1936dad2..4d3279e8 100644 --- a/lib/sqlite3/database.rb +++ b/lib/sqlite3/database.rb @@ -141,6 +141,15 @@ def quote(string) def initialize file, options = {}, zvfs = nil mode = Constants::Open::READWRITE | Constants::Open::CREATE + @tracefunc = nil + @authorizer = nil + @progress_handler = nil + @collations = {} + @functions = {} + @results_as_hash = options[:results_as_hash] + @readonly = false + @default_transaction_mode = options[:default_transaction_mode] || :deferred + file = file.to_path if file.respond_to? :to_path if file.encoding == ::Encoding::UTF_16LE || file.encoding == ::Encoding::UTF_16BE || options[:utf16] open16 file @@ -163,6 +172,8 @@ def initialize file, options = {}, zvfs = nil mode = options[:flags] end + @readonly = mode & Constants::Open::READONLY != 0 + open_v2 file.encode("utf-8"), mode, zvfs if options[:strict] @@ -170,18 +181,9 @@ def initialize file, options = {}, zvfs = nil end end - @tracefunc = nil - @authorizer = nil - @progress_handler = nil - @collations = {} - @functions = {} - @results_as_hash = options[:results_as_hash] - @readonly = mode & Constants::Open::READONLY != 0 - @default_transaction_mode = options[:default_transaction_mode] || :deferred - initialize_extensions(options[:extensions]) - ForkSafety.track(self) + ForkSafety.track(self) if !defined?(Ractor) || (Ractor.main == Ractor.current) if block_given? begin diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb new file mode 100644 index 00000000..009d386e --- /dev/null +++ b/test/test_integration_ractor.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "helper" +require "fileutils" + +class IntegrationRactorTestCase < SQLite3::TestCase + def test_ractor_safe + skip unless RUBY_VERSION >= "3.0" && SQLite3.threadsafe? + skip unless defined?(Ractor) + assert_predicate SQLite3, :ractor_safe? + end + + def test_ractor_share_database + skip("Requires Ruby with Ractors") unless SQLite3.ractor_safe? + + 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 + end + + def test_shareable_db + skip unless defined?(Ractor) + + # databases are shareable between ractors, but only if they're opened + # in "full mutex" mode + db = SQLite3::Database.new ":memory:", + flags: SQLite3::Constants::Open::FULLMUTEX | + SQLite3::Constants::Open::READWRITE | + SQLite3::Constants::Open::CREATE + assert Ractor.shareable?(db) + + db = SQLite3::Database.new ":memory:" + refute Ractor.shareable?(db) + end +end