Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# sqlite3-ruby Changelog

## next / unreleased

### 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 @flavorjones

### 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
Expand Down
98 changes: 98 additions & 0 deletions RACTOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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
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 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.

```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) # use Ractor#take on Ruby 3.4
```

## Supported Ruby versions

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. Sending one to another
Ractor with `Ractor#send` 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

⚠️ 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.
43 changes: 43 additions & 0 deletions ext/sqlite3/exception.c
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <sqlite3_ruby.h>
#include <ruby/thread.h>

static VALUE
status2klass(int status)
Expand Down Expand Up @@ -158,6 +159,43 @@ 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);
}

/* 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)
{
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)
{
Expand Down Expand Up @@ -191,6 +229,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;
Expand All @@ -214,4 +254,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
}
3 changes: 3 additions & 0 deletions ext/sqlite3/extconf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
25 changes: 23 additions & 2 deletions ext/sqlite3/sqlite3.c
Original file line number Diff line number Diff line change
Expand Up @@ -98,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
Expand Down Expand Up @@ -140,6 +154,12 @@ init_sqlite3_constants(void)
VALUE mSqlite3Constants;
VALUE mSqlite3Open;

#ifdef HAVE_RB_EXT_RACTOR_SAFE
if (RTEST(ractor_safe_p(mSqlite3))) {
rb_ext_ractor_safe(true);
}
#endif

mSqlite3Constants = rb_define_module_under(mSqlite3, "Constants");

/* sqlite3_open_v2 flags for Database::new */
Expand Down Expand Up @@ -214,16 +234,17 @@ 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") */
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);
Expand Down
23 changes: 11 additions & 12 deletions ext/sqlite3/statement.c
Original file line number Diff line number Diff line change
Expand Up @@ -596,35 +596,31 @@ 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
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)) {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion lib/sqlite3/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ def initialize file, options = {}, zvfs = nil

initialize_extensions(options[:extensions])

ForkSafety.track(self)
if !defined?(Ractor) || Ractor.main == Ractor.current
ForkSafety.track(self)
end

if block_given?
begin
Expand Down
11 changes: 6 additions & 5 deletions lib/sqlite3/version_info.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
module SQLite3
# a hash of descriptive metadata about the current version of the sqlite3 gem
# 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,
packaged: SQLite3::SQLITE_PACKAGED_LIBRARIES,
precompiled: SQLite3::SQLITE_PRECOMPILED_LIBRARIES,
sqlcipher: SQLite3.sqlcipher?,
threadsafe: SQLite3.threadsafe?
}
}
}.freeze
}.freeze
end
Loading
Loading