From 076954513b321d2de79690b41fcec0922f785500 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Sat, 10 Oct 2026 07:56:48 -0400 Subject: [PATCH 01/15] Ractor support Declare the C extension Ractor-safe when SQLite is built thread-safe, so that each Ractor can open and use its own connections. This finishes #365, rebased onto main, with these changes: - Fill the statement-status symbol table at Init. It was filled lazily on first use, and the "already filled?" check only looked at slot 0, so a second Ractor could see a partly filled table. - Make VERSION_INFO deeply frozen so any Ractor can read it. - Drop the FULLMUTEX "shareable database" part. A Database also holds Ruby state (registered functions, aggregators, collations, handlers), which can't be shared between Ractors even when SQLite's calls are serialized. - Run the Ractor tests in a child process with a timeout. Once a process has started a Ractor, ObjectSpace.each_object stops seeing objects (Ruby 4.0), which broke test_aggregate_instances_are_released_after_each_query when the Ractor tests ran first. Co-authored-by: Roman Sandler <5535625+sandlerr@users.noreply.github.com> Co-authored-by: Aaron Patterson Co-authored-by: Mike Dalessio Co-Authored-By: Claude Opus 5.5 --- RACTOR.md | 85 +++++++++++++++++++++ ext/sqlite3/extconf.rb | 3 + ext/sqlite3/sqlite3.c | 6 ++ ext/sqlite3/statement.c | 23 +++--- lib/sqlite3.rb | 5 ++ lib/sqlite3/database.rb | 2 +- lib/sqlite3/version_info.rb | 6 +- test/test_integration_ractor.rb | 129 ++++++++++++++++++++++++++++++++ 8 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 RACTOR.md create mode 100644 test/test_integration_ractor.rb diff --git a/RACTOR.md b/RACTOR.md new file mode 100644 index 00000000..0d8ef248 --- /dev/null +++ b/RACTOR.md @@ -0,0 +1,85 @@ +# 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 its 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. +`SQLite3.ractor_safe?` reports whether the extension made that declaration. + +The supported pattern is that each Ractor opens and uses its own connections. +Everything works inside a Ractor, including user-defined functions, +aggregators, collations, the authorizer, `trace`, and `busy_handler`. Several +Ractors may open connections to the same database file; SQLite's own locking +coordinates them, just as it does for separate processes. + +```ruby +require "sqlite3" + +ractors = 4.times.map do |i| + Ractor.new(i) do |i| + db = SQLite3::Database.new("app.db") + db.busy_timeout = 5000 + db.execute("INSERT INTO log (worker) VALUES (?)", [i]) + db.close + end +end +ractors.each(&:value) +``` + +## Database objects can't be passed between Ractors + +A `SQLite3::Database` belongs to the Ractor that opened it. Passing one to +another Ractor raises a `Ractor::Error`: + +```ruby +require "sqlite3" + +r = Ractor.new { Ractor.receive } + +db = SQLite3::Database.new ":memory:" + +begin + r.send db + puts "unreachable" +rescue Ractor::Error +end +``` + +This is true even for a connection opened in "Serialized" mode +(`SQLite3::Constants::Open::FULLMUTEX`). Serialized mode makes the SQLite +calls safe, but a `Database` also holds Ruby state: the procs and objects +registered as functions, aggregators, collations, and handlers. Those can't +be shared between Ractors. + +## 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. +Because a connection can't be passed between Ractors, every connection the main +Ractor can reach is tracked. diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index 1fbefd73..8fd4e9f3 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -129,6 +129,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 3e506d19..20e6eb00 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -140,6 +140,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/ext/sqlite3/statement.c b/ext/sqlite3/statement.c index 258a78b4..67ede656 100644 --- a/ext/sqlite3/statement.c +++ b/ext/sqlite3/statement.c @@ -596,26 +596,24 @@ static VALUE stmt_stat_symbols[stmt_stat_sym_last]; static void setup_stmt_stat_symbols(void) { - if (stmt_stat_symbols[0] == 0) { #define S(s) stmt_stat_symbols[stmt_stat_sym_##s] = ID2SYM(rb_intern_const(#s)) - S(fullscan_steps); - S(sorts); - S(autoindexes); - S(vm_steps); + S(fullscan_steps); + S(sorts); + S(autoindexes); + S(vm_steps); #ifdef SQLITE_STMTSTATUS_REPREPARE - S(reprepares); + S(reprepares); #endif #ifdef SQLITE_STMTSTATUS_RUN - S(runs); + S(runs); #endif #ifdef SQLITE_STMTSTATUS_FILTER_MISS - S(filter_misses); + S(filter_misses); #endif #ifdef SQLITE_STMTSTATUS_FILTER_HIT - S(filter_hits); + S(filter_hits); #endif #undef S - } } static size_t @@ -623,8 +621,6 @@ stmt_stat_internal(VALUE hash_or_sym, sqlite3_stmt *stmt) { VALUE hash = Qnil, key = Qnil; - setup_stmt_stat_symbols(); - if (RB_TYPE_P(hash_or_sym, T_HASH)) { hash = hash_or_sym; } else if (SYMBOL_P(hash_or_sym)) { @@ -788,6 +784,9 @@ init_sqlite3_statement(void) { cSqlite3Statement = rb_define_class_under(mSqlite3, "Statement", rb_cObject); + /* populate eagerly: a lazy fill would race when the extension is used from multiple Ractors */ + setup_stmt_stat_symbols(); + rb_define_alloc_func(cSqlite3Statement, allocate); rb_define_method(cSqlite3Statement, "close", sqlite3_rb_close, 0); rb_define_method(cSqlite3Statement, "closed?", closed_p, 0); 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 8939dd8e..f4b59fd2 100644 --- a/lib/sqlite3/database.rb +++ b/lib/sqlite3/database.rb @@ -180,7 +180,7 @@ def initialize file, options = {}, zvfs = nil initialize_extensions(options[:extensions]) - ForkSafety.track(self) + ForkSafety.track(self) if !defined?(Ractor) || (Ractor.main == Ractor.current) if block_given? begin diff --git a/lib/sqlite3/version_info.rb b/lib/sqlite3/version_info.rb index cf869528..66312726 100644 --- a/lib/sqlite3/version_info.rb +++ b/lib/sqlite3/version_info.rb @@ -1,6 +1,5 @@ module SQLite3 - # a hash of descriptive metadata about the current version of the sqlite3 gem - VERSION_INFO = { + version_info = { ruby: RUBY_DESCRIPTION, gem: { version: SQLite3::VERSION @@ -14,4 +13,7 @@ module SQLite3 threadsafe: SQLite3.threadsafe? } } + # a hash of descriptive metadata about the current version of the sqlite3 gem, + # deeply frozen so that it can be read from any Ractor + VERSION_INFO = defined?(Ractor.make_shareable) ? Ractor.make_shareable(version_info) : version_info.freeze end diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb new file mode 100644 index 00000000..ec5a4cd4 --- /dev/null +++ b/test/test_integration_ractor.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "helper" +require "open3" +require "rbconfig" + +# Each Ractor test runs in a child process. Once a process has started a +# Ractor, some process-wide behavior changes for good (for example, +# ObjectSpace.each_object stops seeing objects), which would leak into +# unrelated tests. A child process also lets us fail on a hang instead of +# stalling the suite. +class IntegrationRactorTestCase < SQLite3::TestCase + TIMEOUT = 60 + + PRELUDE = <<~RUBY + require "sqlite3" + Warning[:experimental] = false + def ractor_value(r) = r.respond_to?(:value) ? r.value : r.take + RUBY + + def setup + super + skip("Requires a Ractor-safe build") unless SQLite3.ractor_safe? + end + + def test_ractor_safe + assert_predicate SQLite3, :ractor_safe? + end + + def test_open_and_query_in_ractors + assert_ractor_script(<<~RUBY, "4950,4950,4950,4950") + ractors = 4.times.map do + Ractor.new do + db = SQLite3::Database.new(":memory:") + begin + db.execute "CREATE TABLE t (a INTEGER)" + db.transaction do + stmt = db.prepare("INSERT INTO t VALUES (?)") + 100.times { |n| stmt.execute(n) } + stmt.close + end + db.get_first_value("SELECT sum(a) FROM t") + ensure + db.close + end + end + end + print ractors.map { ractor_value(_1) }.join(",") + RUBY + end + + def test_callbacks_in_ractor + assert_ractor_script(<<~RUBY, "20,b,true,true") + class Summer + def initialize = @sum = 0 + def step(v) = @sum += v + def finalize = @sum + end + + class Reverse + def compare(a, b) = b <=> a + end + + r = Ractor.new do + db = SQLite3::Database.new(":memory:") + begin + traced = false + db.trace { traced = true } + db.authorizer = ->(*) { true } + db.create_function("double", 1) { |fn, x| fn.result = x * 2 } + db.define_aggregator("summer", Summer.new) + db.collation("rev", Reverse.new) + + db.execute "CREATE TABLE t (a INTEGER, s TEXT)" + db.execute "INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'a'), (4, 'b')" + stmt = db.prepare("SELECT s FROM t ORDER BY s COLLATE rev LIMIT 1") + first = stmt.execute.to_a.flatten.first + stats = stmt.stat.key?(:vm_steps) + stmt.close + + [db.get_first_value("SELECT summer(double(a)) FROM t"), first, stats, traced].join(",") + ensure + db.close + end + end + print ractor_value(r) + RUBY + end + + def test_version_info_is_shareable + assert_ractor_script(<<~RUBY, SQLite3::VERSION) + print ractor_value(Ractor.new { SQLite3::VERSION_INFO[:gem][:version] }) + RUBY + end + + def test_database_cannot_be_sent_to_another_ractor + assert_ractor_script(<<~RUBY, "Ractor::Error") + db = SQLite3::Database.new(":memory:") + r = Ractor.new { Ractor.receive } + begin + r.send(db) + print "sent" + rescue Ractor::Error => e + print e.class + end + RUBY + end + + private + + def assert_ractor_script(script, expected) + load_path = $LOAD_PATH.select { |dir| File.directory?(dir) }.flat_map { |dir| ["-I", dir] } + cmd = [RbConfig.ruby, *load_path, "-e", PRELUDE + script] + + Open3.popen2e(*cmd) do |stdin, out, wait_thr| + stdin.close + reader = Thread.new { out.read } + + unless wait_thr.join(TIMEOUT) + Process.kill(:KILL, wait_thr.pid) + flunk "Ractor script did not finish within #{TIMEOUT}s:\n#{script}" + end + + output = reader.value + assert_predicate wait_thr.value, :success?, "Ractor script failed:\n#{output}" + assert_equal expected, output + end + end +end From 2a49ca5e174981c4e7ae364d1df0b1685ac9c5a4 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Sat, 10 Oct 2026 08:53:34 -0400 Subject: [PATCH 02/15] Fix Ractor tests on Ruby 3.2 and under valgrind - Skip the send-a-Database test before Ruby 3.3, which made a shallow copy of T_DATA objects instead of raising (ruby/ruby@ce47ee00). - Compare only the child's stdout; stderr carries unrelated warnings, such as "Free at exit is experimental". - Unset RUBY_FREE_AT_EXIT for the child processes. With it set, Ruby 4.0.7 itself reads freed memory in ractor_free while tearing down any process that created a Ractor: RUBY_FREE_AT_EXIT=1 valgrind ruby -e 'Ractor.new { 1 }.value' Co-Authored-By: Claude Opus 5.5 --- test/test_integration_ractor.rb | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb index ec5a4cd4..0d015e45 100644 --- a/test/test_integration_ractor.rb +++ b/test/test_integration_ractor.rb @@ -94,6 +94,9 @@ def test_version_info_is_shareable end def test_database_cannot_be_sent_to_another_ractor + # Before ruby/ruby@ce47ee00, sending a T_DATA object made a shallow copy instead of raising + skip("Requires Ruby 3.3 or later") if RUBY_VERSION < "3.3" + assert_ractor_script(<<~RUBY, "Ractor::Error") db = SQLite3::Database.new(":memory:") r = Ractor.new { Ractor.receive } @@ -112,17 +115,23 @@ def assert_ractor_script(script, expected) load_path = $LOAD_PATH.select { |dir| File.directory?(dir) }.flat_map { |dir| ["-I", dir] } cmd = [RbConfig.ruby, *load_path, "-e", PRELUDE + script] - Open3.popen2e(*cmd) do |stdin, out, wait_thr| + # RUBY_FREE_AT_EXIT (set by the valgrind task) makes Ruby itself read freed memory while + # tearing down a process that has created a Ractor, so it's unset for these child processes. + # Reproduce with: RUBY_FREE_AT_EXIT=1 valgrind ruby -e 'Ractor.new { 1 }.value' + env = {"RUBY_FREE_AT_EXIT" => nil} + + Open3.popen3(env, *cmd) do |stdin, out, err, wait_thr| stdin.close - reader = Thread.new { out.read } + out_reader = Thread.new { out.read } + err_reader = Thread.new { err.read } unless wait_thr.join(TIMEOUT) Process.kill(:KILL, wait_thr.pid) flunk "Ractor script did not finish within #{TIMEOUT}s:\n#{script}" end - output = reader.value - assert_predicate wait_thr.value, :success?, "Ractor script failed:\n#{output}" + output = out_reader.value + assert_predicate wait_thr.value, :success?, "Ractor script failed:\n#{output}#{err_reader.value}" assert_equal expected, output end end From 56db603ce85068e473905d2027682cdd30376f8e Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Sat, 10 Oct 2026 09:00:05 -0400 Subject: [PATCH 03/15] Skip the Ractor tests under valgrind valgrind follows the child processes, and a Ruby process that has created a Ractor can't exit cleanly under it: with RUBY_FREE_AT_EXIT, Ruby 4.0.7 reads freed memory in ractor_free; without it, everything Ruby allocated is reported as leaked. Neither involves sqlite3. Co-Authored-By: Claude Opus 5.5 --- test/test_integration_ractor.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb index 0d015e45..3d2fa20a 100644 --- a/test/test_integration_ractor.rb +++ b/test/test_integration_ractor.rb @@ -21,6 +21,12 @@ def ractor_value(r) = r.respond_to?(:value) ? r.value : r.take def setup super skip("Requires a Ractor-safe build") unless SQLite3.ractor_safe? + + # valgrind follows the child processes, and a Ruby process that has created a Ractor can't + # exit cleanly under it: with RUBY_FREE_AT_EXIT, Ruby 4.0.7 reads freed memory in + # ractor_free (RUBY_FREE_AT_EXIT=1 valgrind ruby -e 'Ractor.new { 1 }.value'); without it, + # everything Ruby allocated is reported as leaked. + skip("Ractor processes can't exit cleanly under valgrind") if i_am_running_in_valgrind end def test_ractor_safe @@ -115,12 +121,7 @@ def assert_ractor_script(script, expected) load_path = $LOAD_PATH.select { |dir| File.directory?(dir) }.flat_map { |dir| ["-I", dir] } cmd = [RbConfig.ruby, *load_path, "-e", PRELUDE + script] - # RUBY_FREE_AT_EXIT (set by the valgrind task) makes Ruby itself read freed memory while - # tearing down a process that has created a Ractor, so it's unset for these child processes. - # Reproduce with: RUBY_FREE_AT_EXIT=1 valgrind ruby -e 'Ractor.new { 1 }.value' - env = {"RUBY_FREE_AT_EXIT" => nil} - - Open3.popen3(env, *cmd) do |stdin, out, err, wait_thr| + Open3.popen3(*cmd) do |stdin, out, err, wait_thr| stdin.close out_reader = Thread.new { out.read } err_reader = Thread.new { err.read } From dc715d7985fd61e65dfd3a6cc208772f95ad61ae Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Fri, 16 Oct 2026 10:27:05 -0700 Subject: [PATCH 04/15] Lets freeze constants We don't need to call make_shareable on everything :) --- ext/sqlite3/sqlite3.c | 4 ++-- lib/sqlite3/version_info.rb | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index 20e6eb00..9d055c9d 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -223,13 +223,13 @@ Init_sqlite3_native(void) rb_define_singleton_method(mSqlite3, "status", rb_sqlite3_status, -1); /* (String) The version of the sqlite3 library compiled with (e.g., "3.46.1") */ - rb_define_const(mSqlite3, "SQLITE_VERSION", rb_str_new2(SQLITE_VERSION)); + rb_define_const(mSqlite3, "SQLITE_VERSION", rb_obj_freeze(rb_str_new2(SQLITE_VERSION))); /* (Integer) The version of the sqlite3 library compiled with (e.g., 346001) */ rb_define_const(mSqlite3, "SQLITE_VERSION_NUMBER", INT2FIX(SQLITE_VERSION_NUMBER)); /* (String) The version of the sqlite3 library loaded at runtime (e.g., "3.46.1") */ - rb_define_const(mSqlite3, "SQLITE_LOADED_VERSION", rb_str_new2(sqlite3_libversion())); + rb_define_const(mSqlite3, "SQLITE_LOADED_VERSION", rb_obj_freeze(rb_str_new2(sqlite3_libversion()))); #ifdef USING_PACKAGED_LIBRARIES rb_define_const(mSqlite3, "SQLITE_PACKAGED_LIBRARIES", Qtrue); diff --git a/lib/sqlite3/version_info.rb b/lib/sqlite3/version_info.rb index 66312726..d2d5185e 100644 --- a/lib/sqlite3/version_info.rb +++ b/lib/sqlite3/version_info.rb @@ -1,9 +1,11 @@ module SQLite3 - version_info = { + # a hash of descriptive metadata about the current version of the sqlite3 gem, + # deeply frozen so that it can be read from any Ractor + VERSION_INFO = { ruby: RUBY_DESCRIPTION, gem: { - version: SQLite3::VERSION - }, + version: SQLite3::VERSION.freeze + }.freeze, sqlite: { compiled: SQLite3::SQLITE_VERSION, loaded: SQLite3::SQLITE_LOADED_VERSION, @@ -11,9 +13,6 @@ module SQLite3 precompiled: SQLite3::SQLITE_PRECOMPILED_LIBRARIES, sqlcipher: SQLite3.sqlcipher?, threadsafe: SQLite3.threadsafe? - } - } - # a hash of descriptive metadata about the current version of the sqlite3 gem, - # deeply frozen so that it can be read from any Ractor - VERSION_INFO = defined?(Ractor.make_shareable) ? Ractor.make_shareable(version_info) : version_info.freeze + }.freeze + }.freeze end From ce70eab7107055126f8bba5d3f570a94cc450b80 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 15:10:46 -0400 Subject: [PATCH 05/15] doc: add CHANGELOG entries for Ractor support --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af66b1d5..7b9dac75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # sqlite3-ruby Changelog +## next / unreleased + +### Added + +- Ractor support. When SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. #749 @rubys @sandlerr @tenderlove + +### Changed + +- `SQLite3::VERSION`, `SQLite3::VERSION_INFO`, `SQLite3::SQLITE_VERSION` and `SQLite3::SQLITE_LOADED_VERSION` are frozen, so that any Ractor can read them. Code that mutates them now raises `FrozenError`. #749 @rubys @tenderlove + ## 2.10.2 / 2026-10-26 ### Security / Stability From 41bfd95765af80ff90eaa6ad80e7996da11894cc Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 15:10:46 -0400 Subject: [PATCH 06/15] doc: correct RACTOR.md on Ruby 3.2 and fork safety RACTOR.md said that passing a `Database` to another Ractor always raised, but on Ruby 3.2 `Ractor#send` delivers a closed copy, and a Ractor can return a usable connection to the main Ractor. It also said that a forked child releases connections opened in other Ractors; the child can still use one if a non-main Ractor forked or a Ractor returned it. Correct both. --- RACTOR.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/RACTOR.md b/RACTOR.md index 0d8ef248..b5941dfd 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -51,10 +51,11 @@ end ractors.each(&:value) ``` -## Database objects can't be passed between Ractors +## Database objects can't be shared between Ractors -A `SQLite3::Database` belongs to the Ractor that opened it. Passing one to -another Ractor raises a `Ractor::Error`: +A `SQLite3::Database` can't be shared between Ractors. On Ruby 3.3 and later, +sending one to another Ractor with `Ractor#send` raises a `Ractor::Error`. On +Ruby 3.2, the receiving Ractor gets a closed copy that can't be used. ```ruby require "sqlite3" @@ -78,8 +79,8 @@ be shared between Ractors. ## 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. -Because a connection can't be passed between Ractors, every connection the main -Ractor can reach is tracked. +Only connections opened in the main Ractor are tracked for fork safety. A +forked child does not automatically close a writable connection opened in +another Ractor. Such a connection remains usable in the child when a non-main +Ractor forks (Ruby 3.2 through 3.4 allow this) or when a Ractor has returned +the connection to the main Ractor. Close writable connections before forking. From d393a4fef19d156b24479aa43512d264faeefb82 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 15:26:39 -0400 Subject: [PATCH 07/15] doc: correct RACTOR.md on create_aggregate and Ruby 3.x RACTOR.md said that every aggregator method works inside a Ractor, but `Database#create_aggregate` raises `Ractor::IsolationError` there. Its example also called `Ractor#value`, which Ruby 3.2 through 3.4 lack. Name the working alternatives and note `Ractor#take`. --- RACTOR.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/RACTOR.md b/RACTOR.md index b5941dfd..23b916c6 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -32,8 +32,11 @@ extension is Ractor safe, but only if `sqlite3_threadsafe` returns true. `SQLite3.ractor_safe?` reports whether the extension made that declaration. The supported pattern is that each Ractor opens and uses its own connections. -Everything works inside a Ractor, including user-defined functions, -aggregators, collations, the authorizer, `trace`, and `busy_handler`. Several +Everything works inside a Ractor except `Database#create_aggregate`, which +raises `Ractor::IsolationError` outside the main Ractor; use +`Database#define_aggregator` or `Database#create_aggregate_handler` instead. +User-defined functions, collations, the authorizer, `trace`, and +`busy_handler` all work. Several Ractors may open connections to the same database file; SQLite's own locking coordinates them, just as it does for separate processes. @@ -48,7 +51,7 @@ ractors = 4.times.map do |i| db.close end end -ractors.each(&:value) +ractors.each(&:value) # use Ractor#take on Ruby 3.2 through 3.4 ``` ## Database objects can't be shared between Ractors From 02c7e705bc7b6f4ba297293860627446d640e615 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 15:26:39 -0400 Subject: [PATCH 08/15] doc: forking with Ractor-owned connections is unsupported RACTOR.md listed the cases in which a forked child can still use a writable connection opened in a non-main Ractor. State instead that forking after opening such connections is unsupported. --- RACTOR.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/RACTOR.md b/RACTOR.md index 23b916c6..b420b5ac 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -82,8 +82,6 @@ be shared between Ractors. ## Fork Safety -Only connections opened in the main Ractor are tracked for fork safety. A -forked child does not automatically close a writable connection opened in -another Ractor. Such a connection remains usable in the child when a non-main -Ractor forks (Ruby 3.2 through 3.4 allow this) or when a Ractor has returned -the connection to the main Ractor. Close writable connections before forking. +Forking a process that has opened writable connections in non-main Ractors is +not supported and may corrupt those databases. Only connections opened in the +main Ractor are closed in the forked child. From 45bc81d7ce8439f916e6e62184e60917e934e372 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 15:34:26 -0400 Subject: [PATCH 09/15] doc: mark Ractor support as experimental --- RACTOR.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RACTOR.md b/RACTOR.md index b420b5ac..476b0cc1 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -1,5 +1,8 @@ # Ractor support in SQLite3-Ruby +Ractor support is experimental at this time. We welcome bug reports, but we +recommend caution before exercising this support in production applications. + SQLite3 has [3 different modes of threading support](https://www.sqlite.org/threadsafe.html). 1. Single-thread From 9e8f97e406130d5208fd9b069e444f1f82321eba Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 16:16:51 -0400 Subject: [PATCH 10/15] doc: mark Ractor support as experimental in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9dac75..3c07fff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Ractor support. When SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. #749 @rubys @sandlerr @tenderlove +- Experimental Ractor support. When SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove ### Changed From 91280659427ca1ee60ab1b185bb20cd8cd075d0c Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 16:55:22 -0400 Subject: [PATCH 11/15] build: drop redundant `rb_ext_ractor_safe` check Ruby's headers define `HAVE_RB_EXT_RACTOR_SAFE` on every supported Ruby (3.2 and later), so the `have_func` check only slowed the build. Remove it. ref: https://github.com/sparklemotion/sqlite3-ruby/pull/749#discussion_r4382129063 --- ext/sqlite3/extconf.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index 8fd4e9f3..1fbefd73 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -129,9 +129,6 @@ 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") From 229cc8dbdeca8441e285580ef5413cd126a9492b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 18:02:05 -0400 Subject: [PATCH 12/15] fix: deadlock when a callback blocks in a non-main Ractor On Ruby 3.3 and later, the M:N thread scheduler can resume a non-main Ractor's thread on a different native thread after a callback blocks, for example in `sleep`. SQLite holds the connection mutex while the callback runs, and only the native thread that locked it can unlock it, so the connection deadlocked. Call `rb_thread_lock_native_thread()` before a callback's Ruby code runs. This keeps the thread on one native thread from then on. Each call increments a counter in Ruby with no matching decrement, so call it only once per fiber, and record each call in a fiber-local. Ruby 3.3 doesn't have that function, so don't declare the extension Ractor-safe on 3.3. --- CHANGELOG.md | 2 +- RACTOR.md | 8 +++ ext/sqlite3/exception.c | 42 +++++++++++++++ ext/sqlite3/extconf.rb | 3 ++ ext/sqlite3/sqlite3.c | 5 +- lib/sqlite3.rb | 2 +- test/test_integration_ractor.rb | 94 +++++++++++++++++++++++++++++++++ test/test_sqlite3.rb | 5 ++ 8 files changed, 158 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c07fff9..d5415b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Experimental Ractor support. When SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove +- Experimental Ractor support. When SQLite is compiled thread-safe, the extension declares itself Ractor-safe (except on Ruby 3.3), and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove ### Changed diff --git a/RACTOR.md b/RACTOR.md index 476b0cc1..6b33a2a1 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -57,6 +57,14 @@ end ractors.each(&:value) # use Ractor#take on Ruby 3.2 through 3.4 ``` +## Ruby 3.3 + +SQLite3-Ruby does not support non-main Ractors on Ruby 3.3. On Ruby 3.3, +opening a database in a non-main Ractor raises `Ractor::UnsafeError` ("ractor +unsafe method called from not main ractor"), because Ruby 3.3 is missing +`rb_thread_lock_native_thread()`, which SQLite3-Ruby needs to run callbacks +safely in non-main Ractors. + ## Database objects can't be shared between Ractors A `SQLite3::Database` can't be shared between Ractors. On Ruby 3.3 and later, diff --git a/ext/sqlite3/exception.c b/ext/sqlite3/exception.c index 40abbbb7..c1cf2a61 100644 --- a/ext/sqlite3/exception.c +++ b/ext/sqlite3/exception.c @@ -1,4 +1,5 @@ #include +#include static VALUE status2klass(int status) @@ -158,6 +159,42 @@ current_callback_frames(void) return NIL_P(holder) ? NULL : (callback_frames_t *)RTYPEDDATA_DATA(holder); } +#ifdef HAVE_RB_THREAD_LOCK_NATIVE_THREAD +static ID id_native_thread_locked; + +static VALUE +mark_native_thread_locked(VALUE thread) +{ + return rb_thread_local_aset(thread, id_native_thread_locked, thread); +} + +/* SQLite holds the connection mutex while it runs a callback, and only the native thread that + * locked it can unlock it. If the callback blocks, the M:N scheduler can resume a non-main + * Ractor's thread on another native thread, so keep the thread on this one before any Ruby code + * runs. Each call to rb_thread_lock_native_thread increments a counter with no matching + * decrement, so record the call in a fiber-local, which can be read without running Ruby code. + * The fiber-local holds the Thread, so a copy in another thread's fiber-locals doesn't count. + * A frozen Thread's fiber-locals can't be set, so on a frozen Thread every callback calls + * rb_thread_lock_native_thread again. */ +static void +lock_native_thread(void) +{ + VALUE thread = rb_thread_current(); + int state; + + if (rb_thread_local_aref(thread, id_native_thread_locked) == thread) { return; } + + rb_thread_lock_native_thread(); + rb_protect(mark_native_thread_locked, thread, &state); + if (state) { rb_set_errinfo(Qnil); } +} +#else +static void +lock_native_thread(void) +{ +} +#endif + void rb_sqlite3_push_callback_frame(rb_sqlite3_callback_frame_t *frame) { @@ -191,6 +228,8 @@ rb_sqlite3_protect_callback(VALUE (*body)(VALUE), VALUE data, int *failed) VALUE result; int state; + lock_native_thread(); + if (frame && frame->state) { *failed = 1; return Qnil; @@ -214,4 +253,7 @@ void init_sqlite3_exception(void) { id_callback_frames = rb_intern("__sqlite3_callback_frames__"); +#ifdef HAVE_RB_THREAD_LOCK_NATIVE_THREAD + id_native_thread_locked = rb_intern("__sqlite3_native_thread_locked__"); +#endif } diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index 1fbefd73..96d8ad06 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -129,6 +129,9 @@ def configure_extension # Functions defined in 2.1 but not 2.0 have_func("rb_integer_pack") + # Functions defined in 3.4 but not 3.3 + have_func("rb_thread_lock_native_thread", "ruby/thread.h") + # 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 9d055c9d..71c9168c 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -1,4 +1,5 @@ #include +#include VALUE mSqlite3; VALUE cSqlite3Blob; @@ -140,7 +141,9 @@ init_sqlite3_constants(void) VALUE mSqlite3Constants; VALUE mSqlite3Open; -#ifdef HAVE_RB_EXT_RACTOR_SAFE + /* Ruby 3.3 has the M:N scheduler but not rb_thread_lock_native_thread, see + * lock_native_thread in exception.c */ +#if defined(HAVE_RB_EXT_RACTOR_SAFE) && !(RUBY_API_VERSION_MAJOR == 3 && RUBY_API_VERSION_MINOR == 3) if (sqlite3_threadsafe()) { rb_ext_ractor_safe(true); } diff --git a/lib/sqlite3.rb b/lib/sqlite3.rb index 837c936f..1d6d4f75 100644 --- a/lib/sqlite3.rb +++ b/lib/sqlite3.rb @@ -17,7 +17,7 @@ def self.threadsafe? # Is the gem's C extension marked as Ractor-safe? def self.ractor_safe? - threadsafe? && !defined?(Ractor).nil? + threadsafe? && !defined?(Ractor).nil? && !RUBY_VERSION.start_with?("3.3.") end end diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb index 3d2fa20a..f72460d4 100644 --- a/test/test_integration_ractor.rb +++ b/test/test_integration_ractor.rb @@ -93,6 +93,100 @@ def compare(a, b) = b <=> a RUBY end + def test_callback_that_blocks_in_ractors + assert_ractor_script(<<~RUBY, "20,20") + ractors = 2.times.map do + Ractor.new do + db = SQLite3::Database.new(":memory:") + begin + db.create_function("nap", 0) { |fn| sleep 0.01; fn.result = 1 } + 20.times.sum { db.get_first_value("SELECT nap()") } + ensure + db.close + end + end + end + print ractors.map { ractor_value(_1) }.join(",") + RUBY + end + + def test_callback_that_blocks_outside_a_statement_in_ractors + skip("Requires sqlite3_serialize") unless SQLite3::Database.method_defined?(:serialize) + + assert_ractor_script(<<~RUBY, "20,20") + ractors = 2.times.map do + Ractor.new do + db = SQLite3::Database.new(":memory:") + begin + db.authorizer = ->(*) { sleep 0.01; true } + 20.times.count { db.serialize } + ensure + db.close + end + end + end + print ractors.map { ractor_value(_1) }.join(",") + RUBY + end + + def test_trace_point_that_blocks_in_ractor_callbacks + assert_ractor_script(<<~RUBY, "8") + ractors = 8.times.map do + Ractor.new do + db = SQLite3::Database.new(":memory:") + begin + db.create_function("one", 0) { |fn| fn.result = 1 } + TracePoint.new(:c_return) { sleep 0.001 }.enable do + db.get_first_value("SELECT one()") + end + ensure + db.close + end + end + end + print ractors.sum { ractor_value(_1) } + RUBY + end + + def test_callback_that_blocks_in_a_thread_with_copied_fiber_locals_in_ractors + assert_ractor_script(<<~RUBY, "20,20") + ractors = 2.times.map do + Ractor.new do + db = SQLite3::Database.new(":memory:") + begin + db.create_function("nap", 0) { |fn| sleep 0.01; fn.result = 1 } + db.get_first_value("SELECT nap()") + locals = Thread.current.keys.to_h { [_1, Thread.current[_1]] } + Thread.new do + locals.each { |key, value| Thread.current[key] = value } + 20.times.sum { db.get_first_value("SELECT nap()") } + end.value + ensure + db.close + end + end + end + print ractors.map { ractor_value(_1) }.join(",") + RUBY + end + + def test_exit_while_a_ractor_callback_blocks + assert_ractor_script(<<~RUBY, "exiting") + 2.times do + Ractor.new do + db = SQLite3::Database.new(":memory:") + db.trace do + Ractor.main.send(:started) + sleep + end + db.execute("SELECT 1") + end + end + 2.times { Ractor.receive } + print "exiting" + RUBY + end + def test_version_info_is_shareable assert_ractor_script(<<~RUBY, SQLite3::VERSION) print ractor_value(Ractor.new { SQLite3::VERSION_INFO[:gem][:version] }) diff --git a/test/test_sqlite3.rb b/test/test_sqlite3.rb index 364734b2..2d208629 100644 --- a/test/test_sqlite3.rb +++ b/test/test_sqlite3.rb @@ -18,6 +18,11 @@ def test_threadsafe? end end + def test_not_ractor_safe_on_ruby_3_3 + skip("Requires Ruby 3.3") unless RUBY_VERSION.start_with?("3.3.") + refute_predicate SQLite3, :ractor_safe? + end + def test_compiled_version_and_loaded_version assert_equal(SQLite3::SQLITE_VERSION, SQLite3::SQLITE_LOADED_VERSION) end From efa3d711e1888d5279361d159fb3643d9362c715 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 27 Oct 2026 10:47:10 -0400 Subject: [PATCH 13/15] ext: decide Ractor safety from the HAVE macros The C extension and `SQLite3.ractor_safe?` each checked the Ruby version to leave out Ruby 3.3, which lacks `rb_thread_lock_native_thread()`. Check `HAVE_RB_THREAD_LOCK_NATIVE_THREAD` instead, and define `SQLite3.ractor_safe?` in C from the same check, so the two can't disagree. Ractor support now needs Ruby 3.4 or later, so Ruby 3.2 is no longer declared Ractor-safe either. --- CHANGELOG.md | 2 +- RACTOR.md | 20 ++++++++++---------- ext/sqlite3/sqlite3.c | 22 +++++++++++++++++----- lib/sqlite3.rb | 5 ----- test/test_integration_ractor.rb | 3 --- test/test_sqlite3.rb | 23 ++++++++++++++++++++--- 6 files changed, 48 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5415b79..149c47f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Experimental Ractor support. When SQLite is compiled thread-safe, the extension declares itself Ractor-safe (except on Ruby 3.3), and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove +- Experimental Ractor support. On Ruby 3.4 and later, when SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove ### Changed diff --git a/RACTOR.md b/RACTOR.md index 6b33a2a1..73cacc30 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -54,22 +54,22 @@ ractors = 4.times.map do |i| db.close end end -ractors.each(&:value) # use Ractor#take on Ruby 3.2 through 3.4 +ractors.each(&:value) # use Ractor#take on Ruby 3.4 ``` -## Ruby 3.3 +## Supported Ruby versions -SQLite3-Ruby does not support non-main Ractors on Ruby 3.3. On Ruby 3.3, -opening a database in a non-main Ractor raises `Ractor::UnsafeError` ("ractor -unsafe method called from not main ractor"), because Ruby 3.3 is missing -`rb_thread_lock_native_thread()`, which SQLite3-Ruby needs to run callbacks -safely in non-main Ractors. +Ractor support requires Ruby 3.4 or later. SQLite3-Ruby needs +`rb_thread_lock_native_thread()` to run callbacks safely in non-main Ractors, +and only Ruby 3.4 and later have it. On earlier versions, +`SQLite3.ractor_safe?` returns false, and opening a database in a non-main +Ractor raises `Ractor::UnsafeError` ("ractor unsafe method called from not +main ractor"). ## Database objects can't be shared between Ractors -A `SQLite3::Database` can't be shared between Ractors. On Ruby 3.3 and later, -sending one to another Ractor with `Ractor#send` raises a `Ractor::Error`. On -Ruby 3.2, the receiving Ractor gets a closed copy that can't be used. +A `SQLite3::Database` can't be shared between Ractors. Sending one to another +Ractor with `Ractor#send` raises a `Ractor::Error`. ```ruby require "sqlite3" diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index 71c9168c..d15c53b4 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -1,5 +1,4 @@ #include -#include VALUE mSqlite3; VALUE cSqlite3Blob; @@ -99,6 +98,20 @@ threadsafe_p(VALUE UNUSED(klass)) return INT2NUM(sqlite3_threadsafe()); } +/* Returns true if the extension declared itself Ractor-safe. That needs SQLite compiled + * thread-safe and Ruby 3.4 or later, which has rb_thread_lock_native_thread (see + * lock_native_thread in exception.c). + */ +static VALUE +ractor_safe_p(VALUE UNUSED(klass)) +{ +#if defined(HAVE_RB_EXT_RACTOR_SAFE) && defined(HAVE_RB_THREAD_LOCK_NATIVE_THREAD) + return sqlite3_threadsafe() ? Qtrue : Qfalse; +#else + return Qfalse; +#endif +} + /* * call-seq: * status(parameter) → Hash @@ -141,10 +154,8 @@ init_sqlite3_constants(void) VALUE mSqlite3Constants; VALUE mSqlite3Open; - /* Ruby 3.3 has the M:N scheduler but not rb_thread_lock_native_thread, see - * lock_native_thread in exception.c */ -#if defined(HAVE_RB_EXT_RACTOR_SAFE) && !(RUBY_API_VERSION_MAJOR == 3 && RUBY_API_VERSION_MINOR == 3) - if (sqlite3_threadsafe()) { +#ifdef HAVE_RB_EXT_RACTOR_SAFE + if (RTEST(ractor_safe_p(mSqlite3))) { rb_ext_ractor_safe(true); } #endif @@ -223,6 +234,7 @@ Init_sqlite3_native(void) rb_define_singleton_method(mSqlite3, "sqlcipher?", using_sqlcipher, 0); rb_define_singleton_method(mSqlite3, "libversion", libversion, 0); rb_define_singleton_method(mSqlite3, "threadsafe", threadsafe_p, 0); + rb_define_singleton_method(mSqlite3, "ractor_safe?", ractor_safe_p, 0); rb_define_singleton_method(mSqlite3, "status", rb_sqlite3_status, -1); /* (String) The version of the sqlite3 library compiled with (e.g., "3.46.1") */ diff --git a/lib/sqlite3.rb b/lib/sqlite3.rb index 1d6d4f75..93caef14 100644 --- a/lib/sqlite3.rb +++ b/lib/sqlite3.rb @@ -14,11 +14,6 @@ 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? && !RUBY_VERSION.start_with?("3.3.") - end end require "sqlite3/version_info" diff --git a/test/test_integration_ractor.rb b/test/test_integration_ractor.rb index f72460d4..47ad315b 100644 --- a/test/test_integration_ractor.rb +++ b/test/test_integration_ractor.rb @@ -194,9 +194,6 @@ def test_version_info_is_shareable end def test_database_cannot_be_sent_to_another_ractor - # Before ruby/ruby@ce47ee00, sending a T_DATA object made a shallow copy instead of raising - skip("Requires Ruby 3.3 or later") if RUBY_VERSION < "3.3" - assert_ractor_script(<<~RUBY, "Ractor::Error") db = SQLite3::Database.new(":memory:") r = Ractor.new { Ractor.receive } diff --git a/test/test_sqlite3.rb b/test/test_sqlite3.rb index 2d208629..2d6ed48e 100644 --- a/test/test_sqlite3.rb +++ b/test/test_sqlite3.rb @@ -1,4 +1,6 @@ require "helper" +require "open3" +require "rbconfig" module SQLite3 class TestSQLite3 < SQLite3::TestCase @@ -18,9 +20,24 @@ def test_threadsafe? end end - def test_not_ractor_safe_on_ruby_3_3 - skip("Requires Ruby 3.3") unless RUBY_VERSION.start_with?("3.3.") - refute_predicate SQLite3, :ractor_safe? + def test_non_main_ractor_cannot_open_a_database_unless_ractor_safe + skip("Requires a build that is not Ractor-safe") if SQLite3.ractor_safe? + skip("Ractor processes can't exit cleanly under valgrind") if i_am_running_in_valgrind + + load_path = $LOAD_PATH.flat_map { |dir| ["-I", dir] } + output, error, status = Open3.capture3(RbConfig.ruby, *load_path, "-e", <<~RUBY) + require "sqlite3" + Warning[:experimental] = false + r = Ractor.new { SQLite3::Database.new(":memory:") } + begin + r.respond_to?(:value) ? r.value : r.take + rescue Ractor::RemoteError => e + print e.cause.class + end + RUBY + + assert_predicate status, :success?, error + assert_equal "Ractor::UnsafeError", output end def test_compiled_version_and_loaded_version From e4f3912097843ade5513945082779dca6eff8351 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 27 Oct 2026 10:47:10 -0400 Subject: [PATCH 14/15] doc: review notes on Ractor support Credit @flavorjones in the CHANGELOG, flag the RACTOR.md fork-safety warning with an emoji, shorten the comment on `lock_native_thread` to say why it pins and why it records the pin, and use an `if` block for the `ForkSafety.track` condition. --- CHANGELOG.md | 2 +- RACTOR.md | 2 +- ext/sqlite3/exception.c | 17 +++++++++-------- lib/sqlite3/database.rb | 4 +++- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 149c47f9..a8ae8457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Experimental Ractor support. On Ruby 3.4 and later, when SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove +- Experimental Ractor support. On Ruby 3.4 and later, when SQLite is compiled thread-safe, the extension declares itself Ractor-safe, and each Ractor can open and use its own connections. `SQLite3.ractor_safe?` reports whether the extension made the declaration. See [RACTOR.md](RACTOR.md) for the limits on sharing connections between Ractors and on fork safety. We welcome bug reports, but we recommend caution before using Ractor support in production applications. #749 @rubys @sandlerr @tenderlove @flavorjones ### Changed diff --git a/RACTOR.md b/RACTOR.md index 73cacc30..40885458 100644 --- a/RACTOR.md +++ b/RACTOR.md @@ -93,6 +93,6 @@ be shared between Ractors. ## Fork Safety -Forking a process that has opened writable connections in non-main Ractors is +⚠️ Forking a process that has opened writable connections in non-main Ractors is not supported and may corrupt those databases. Only connections opened in the main Ractor are closed in the forked child. diff --git a/ext/sqlite3/exception.c b/ext/sqlite3/exception.c index c1cf2a61..ec8c279a 100644 --- a/ext/sqlite3/exception.c +++ b/ext/sqlite3/exception.c @@ -168,14 +168,15 @@ mark_native_thread_locked(VALUE thread) return rb_thread_local_aset(thread, id_native_thread_locked, thread); } -/* SQLite holds the connection mutex while it runs a callback, and only the native thread that - * locked it can unlock it. If the callback blocks, the M:N scheduler can resume a non-main - * Ractor's thread on another native thread, so keep the thread on this one before any Ruby code - * runs. Each call to rb_thread_lock_native_thread increments a counter with no matching - * decrement, so record the call in a fiber-local, which can be read without running Ruby code. - * The fiber-local holds the Thread, so a copy in another thread's fiber-locals doesn't count. - * A frozen Thread's fiber-locals can't be set, so on a frozen Thread every callback calls - * rb_thread_lock_native_thread again. */ +/* Pin the current Ruby thread to its native thread before a callback runs any Ruby code. SQLite + * holds the connection mutex while it runs a callback, and only the native thread that locked the + * mutex can unlock it. In a non-main Ractor, the M:N scheduler can move a thread that blocks in a + * callback to another native thread, and the connection deadlocks. + * + * Each call to rb_thread_lock_native_thread increments a counter with no matching decrement, so + * call it once per fiber, and record the call in a fiber-local. Reading a fiber-local runs no Ruby + * code, and Ruby code here could block before the pin. The fiber-local holds the Thread itself, + * so a copy of it in another thread doesn't count. */ static void lock_native_thread(void) { diff --git a/lib/sqlite3/database.rb b/lib/sqlite3/database.rb index f4b59fd2..1b5be1bc 100644 --- a/lib/sqlite3/database.rb +++ b/lib/sqlite3/database.rb @@ -180,7 +180,9 @@ def initialize file, options = {}, zvfs = nil initialize_extensions(options[:extensions]) - ForkSafety.track(self) if !defined?(Ractor) || (Ractor.main == Ractor.current) + if !defined?(Ractor) || Ractor.main == Ractor.current + ForkSafety.track(self) + end if block_given? begin From ce16f192db0f013255b8be58347e7b52c3f72046 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 27 Oct 2026 10:49:11 -0400 Subject: [PATCH 15/15] test: skip the Ractor-safety test where Ruby has no Ractor TruffleRuby has no `Ractor`, so the subprocess raised `NameError` and the test failed. --- test/test_sqlite3.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_sqlite3.rb b/test/test_sqlite3.rb index 2d6ed48e..aabb3591 100644 --- a/test/test_sqlite3.rb +++ b/test/test_sqlite3.rb @@ -21,6 +21,7 @@ def test_threadsafe? end def test_non_main_ractor_cannot_open_a_database_unless_ractor_safe + skip("Requires Ractor") unless defined?(Ractor) skip("Requires a build that is not Ractor-safe") if SQLite3.ractor_safe? skip("Ractor processes can't exit cleanly under valgrind") if i_am_running_in_valgrind