Skip to content

Commit 144a503

Browse files
committed
Database connections carried across fork() will not be fully closed
Sqlite is not fork-safe and closing the database connection in the child process can lead to corruption. Emit a warning when this happens so developers know when they're doing something that's not supported by sqlite. See adr/2024-09-fork-safety.md for a full explanation.
1 parent 8324f27 commit 144a503

File tree

7 files changed

+178
-19
lines changed

7 files changed

+178
-19
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## next / unreleased
44

5+
### Changed
6+
7+
- Any database connections carried across a `fork()` will not be fully closed, as it is unsafe to do so at the risk of database corruption. If an connection opened in the parent process is being closed in the child process, then a warning will be emitted and some reserved memory will be lost to the child process permanently. See `adr/2024-09-fork-safety.md` for more information. [#558] @flavorjones
8+
9+
510
### Improved
611

712
- Use `sqlite3_close_v2` to close databases in a deferred manner if there are unclosed prepared statements. Previously closing a database while statements were open resulted in a `BusyException`. See https://www.sqlite.org/c3ref/close.html for more context. [#557] @flavorjones

adr/2024-09-fork-safety.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
2+
# 2024-09 Discard database connections when carried across fork()of fork safety
3+
4+
## Status
5+
6+
Accepted, but we can revisit more complex solutions if we learn something that indicates that effort is worth it.
7+
8+
9+
## Context
10+
11+
In August 2024, Andy Croll opened an issue[^issue] describing sqlite file corruption related to solid queue. After investigation, we were able to reproduce corruption under certain circumstances when forking a process with open sqlite databases.[^repro]
12+
13+
SQLite is known to not be fork-safe[^howto], so this was not entirely surprising though it was the first time your author had personally seen corruption in the wild. The corruption became much more likely after the sqlite3-ruby gem improved its memory management with respect to open statements[^gemleak] in v2.0.0.
14+
15+
Advice from upstream contributors[^advice] is, essentially: don't fork if you have open database connections. Or, if you have forked, don't call `sqlite3_close` on those connections (which will result in a memory leak in the child process). Neither of these options are great, see below.
16+
17+
18+
## Decision
19+
20+
Open database connections carried across a `fork()` will not be fully closed, to avoid the risk of corrupting the database file.
21+
22+
The sqlite3-ruby gem will track the ID of the process that opened each database connection. If, when the database is closed (either explicitly with `Database#close` or implicitly via GC) the current process ID is different from the original process, then we "discard" the connection.
23+
24+
"Discard" here means:
25+
26+
- The `Database` object acts "closed", including returning `true` from `#closed?`.
27+
- `sqlite3_close_v2` is not called on the object, because it is unsafe to do so per sqlite instructions[^howto]. As a result, some memory will be lost permanently (a one-time "memory leak").
28+
- Open file descriptors associated with the database are closed.
29+
30+
31+
## Consequences
32+
33+
The positive consequence is that we remove a potential cause of database corruption for applications that fork with active sqlite database connections.
34+
35+
The negative consequence is that, for each discarded connection, some memory will be permanently lost (leaked) in the child process.
36+
37+
38+
## Alternatives considered.
39+
40+
### 1. Require applications to close database connections before forking.
41+
42+
This is the advice[^advice] given by the upstream maintainers of sqlite, and so was the first thing we tried to implement in Rails in [rails/rails#52931](https://github.com/rails/rails/pull/52931)[^before_fork]. That first simple implementation was not thread safe, however, and in order to make it thread-safe it would be necessary to pause all sqlite database activity, close the open connections, and then fork. At least one Rails core team member was not happy that this would interfere with database connections in the parent, and the complexity of a thread-safe solution seemed high, so this work was paused.
43+
44+
### 2. Memory arena
45+
46+
Sqlite offers a configuration option to specify custom memory functions for malloc et al. It seems possible that the sqlite3-ruby gem could implement a custom arena that would be used by sqlite so that in a new process, after forking, all the memory underlying the sqlite Ruby objects could be discarded in a single operation.
47+
48+
I think this approach is promising, but complex and risky. Sqlite is a complex library and uses shared memory in addition to the traditional heap. Would throwing away the heap memory (the arena) result in a segfault or other undefined behaviors or corruption? Determining the answer to that question feels expensive in and of itself, and any solution along these lines would not be supported by the sqlite authors. We can explore this space if the memory leak from `Database#discard` turns out to be a large source of pain.
49+
50+
51+
## References
52+
53+
- [Database#discard by flavorjones · Pull Request #558 · sparklemotion/sqlite3-ruby](https://github.com/sparklemotion/sqlite3-ruby/pull/558)
54+
- TODO rails pr implementing sqlite3adapter discard
55+
56+
57+
## Footnotes
58+
59+
[^issue]: [SQLite queue database corruption · Issue #324 · rails/solid_queue](https://github.com/rails/solid_queue/issues/324)
60+
[^repro]: [flavorjones/2024-09-13-sqlite-corruption: Temporary repo, reproduction of sqlite database corruption.](https://github.com/flavorjones/2024-09-13-sqlite-corruption)
61+
[^howto]: [How To Corrupt An SQLite Database File: §2.6 Carrying an open database connection across a fork()](https://www.sqlite.org/howtocorrupt.html#_carrying_an_open_database_connection_across_a_fork_)
62+
[^gemleak]: [Always call sqlite3_finalize in deallocate func by haileys · Pull Request #392 · sparklemotion/sqlite3-ruby](https://github.com/sparklemotion/sqlite3-ruby/pull/392)
63+
[^advice]: [SQLite Forum: Correct way of carrying connections over forked processes](https://sqlite.org/forum/forumpost/1fa07728204567a0a136f442cb1c59e3117da96898b7fa3290b0063ae7f6f012)
64+
[^before_fork]: [SQLite3Adapter: Ensure fork-safety by flavorjones · Pull Request #52931 · rails/rails](https://github.com/rails/rails/pull/52931#issuecomment-2351365601)
65+
[^config]: [SQlite3 Configuration Options](https://www.sqlite.org/c3ref/c_config_covering_index_scan.html)

ext/sqlite3/database.c

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,38 @@
1212

1313
VALUE cSqlite3Database;
1414

15+
static void
16+
close_or_discard_db(sqlite3RubyPtr ctx)
17+
{
18+
if (ctx->db) {
19+
if (ctx->owner == getpid()) {
20+
// Ordinary close.
21+
sqlite3_close_v2(ctx->db);
22+
} else {
23+
// This is an open connection carried across a fork().
24+
// Discard it. See adr/2024-09-fork-safety.md
25+
sqlite3_file *sfile;
26+
int status;
27+
28+
rb_warning("An open sqlite database connection was inherited from a forked process and "
29+
"is being discarded. This is a memory leak. If possible, please close all sqlite "
30+
"database connections before forking.");
31+
32+
status = sqlite3_file_control(ctx->db, NULL, SQLITE_FCNTL_FILE_POINTER, &sfile);
33+
if (status == 0 && sfile->pMethods != NULL) {
34+
sfile->pMethods->xClose(sfile);
35+
}
36+
37+
status = sqlite3_file_control(ctx->db, NULL, SQLITE_FCNTL_JOURNAL_POINTER, &sfile);
38+
if (status == 0 && sfile->pMethods != NULL) {
39+
sfile->pMethods->xClose(sfile);
40+
}
41+
}
42+
ctx->db = NULL;
43+
}
44+
}
45+
46+
1547
static void
1648
database_mark(void *ctx)
1749
{
@@ -22,11 +54,8 @@ database_mark(void *ctx)
2254
static void
2355
deallocate(void *ctx)
2456
{
25-
sqlite3RubyPtr c = (sqlite3RubyPtr)ctx;
26-
sqlite3 *db = c->db;
27-
28-
if (db) { sqlite3_close_v2(db); }
29-
xfree(c);
57+
close_or_discard_db((sqlite3RubyPtr)ctx);
58+
xfree(ctx);
3059
}
3160

3261
static size_t
@@ -51,7 +80,9 @@ static VALUE
5180
allocate(VALUE klass)
5281
{
5382
sqlite3RubyPtr ctx;
54-
return TypedData_Make_Struct(klass, sqlite3Ruby, &database_type, ctx);
83+
VALUE object = TypedData_Make_Struct(klass, sqlite3Ruby, &database_type, ctx);
84+
ctx->owner = getpid();
85+
return object;
5586
}
5687

5788
static char *
@@ -62,8 +93,6 @@ utf16_string_value_ptr(VALUE str)
6293
return RSTRING_PTR(str);
6394
}
6495

65-
static VALUE sqlite3_rb_close(VALUE self);
66-
6796
sqlite3RubyPtr
6897
sqlite3_database_unwrap(VALUE database)
6998
{
@@ -119,21 +148,22 @@ rb_sqlite3_disable_quirk_mode(VALUE self)
119148
#endif
120149
}
121150

122-
/* call-seq: db.close
151+
/*
152+
* Close the database and release all associated resources.
123153
*
124-
* Closes this database.
154+
* ⚠ If the process that created the database forks a child process, and this method is called
155+
* from the child process, then this method will _not_ free memory resources and instead will
156+
* call discard. This is a memory leak, but is safer than risking database corruption.
157+
*
158+
* See adr/2024-09-fork-safety.md for more information on fork safety.
125159
*/
126160
static VALUE
127161
sqlite3_rb_close(VALUE self)
128162
{
129163
sqlite3RubyPtr ctx;
130-
sqlite3 *db;
131164
TypedData_Get_Struct(self, sqlite3Ruby, &database_type, ctx);
132165

133-
db = ctx->db;
134-
CHECK(db, sqlite3_close_v2(ctx->db));
135-
136-
ctx->db = NULL;
166+
close_or_discard_db(ctx);
137167

138168
rb_iv_set(self, "-aggregators", Qnil);
139169

ext/sqlite3/database.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct _sqlite3Ruby {
88
VALUE busy_handler;
99
int stmt_timeout;
1010
struct timespec stmt_deadline;
11+
pid_t owner;
1112
};
1213

1314
typedef struct _sqlite3Ruby sqlite3Ruby;

test/helper.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
require "sqlite3"
22
require "minitest/autorun"
33

4-
if ENV["GITHUB_ACTIONS"] == "true" || ENV["CI"]
5-
$VERBOSE = nil
6-
end
7-
84
puts "info: ruby version: #{RUBY_DESCRIPTION}"
95
puts "info: gem version: #{SQLite3::VERSION}"
106
puts "info: sqlite version: #{SQLite3::SQLITE_VERSION}/#{SQLite3::SQLITE_LOADED_VERSION}"
@@ -20,5 +16,10 @@ class TestCase < Minitest::Test
2016
def assert_nothing_raised
2117
yield
2218
end
19+
20+
def i_am_running_in_valgrind
21+
# https://stackoverflow.com/questions/365458/how-can-i-detect-if-a-program-is-running-from-within-valgrind/62364698#62364698
22+
ENV["LD_PRELOAD"] =~ /valgrind|vgpreload/
23+
end
2324
end
2425
end

test/test_database.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,5 +721,42 @@ def test_transaction_returns_block_result
721721
result = @db.transaction { :foo }
722722
assert_equal :foo, result
723723
end
724+
725+
def test_discard_a_connection
726+
skip("valgrind doesn't handle forking") if i_am_running_in_valgrind
727+
728+
begin
729+
read, write = IO.pipe
730+
731+
db = SQLite3::Database.new("test.db")
732+
fork do
733+
$stderr = StringIO.new
734+
735+
result = db.close
736+
737+
write.write((result == db) ? "ok\n" : "fail\n")
738+
write.write(db.closed? ? "ok\n" : "fail\n")
739+
write.write($stderr.string)
740+
741+
write.close
742+
read.close
743+
exit!
744+
end
745+
746+
assert_equal("ok", read.readline.chomp, "return value was not the database")
747+
assert_equal("ok", read.readline.chomp, "closed? did not return true")
748+
assert_match(
749+
/warning: An open sqlite database connection was inherited from a forked process/,
750+
read.readline,
751+
"expected warning was not emitted"
752+
)
753+
754+
write.close
755+
read.close
756+
ensure
757+
db.close
758+
FileUtils.rm_f("test.db")
759+
end
760+
end
724761
end
725762
end

test/test_resource_cleanup.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,31 @@ def test_cleanup_unclosed_statement_object
1717
end
1818
end
1919

20+
# # this leaks the result set
2021
# def test_cleanup_unclosed_resultset_object
2122
# db = SQLite3::Database.new(':memory:')
2223
# db.execute('create table foo(text BLOB)')
2324
# stmt = db.prepare('select * from foo')
2425
# stmt.execute
2526
# end
27+
28+
# # this leaks the incompletely-closed connection
29+
# def test_cleanup_discarded_connections
30+
# FileUtils.rm_f "test.db"
31+
# db = SQLite3::Database.new("test.db")
32+
# db.execute("create table posts (title text)")
33+
# db.execute("insert into posts (title) values ('hello')")
34+
# db.close
35+
# 100.times do
36+
# db = SQLite3::Database.new("test.db")
37+
# db.execute("select * from posts limit 1")
38+
# stmt = db.prepare("select * from posts")
39+
# stmt.execute
40+
# stmt.close
41+
# db.discard
42+
# end
43+
# ensure
44+
# FileUtils.rm_f "test.db"
45+
# end
2646
end
2747
end

0 commit comments

Comments
 (0)