Skip to content

Commit df07939

Browse files
committed
feat: Database#discard prevent corruption after fork()
See adr/2024-09-fork-safety.md for a full explanation.
1 parent 6c274b4 commit df07939

File tree

5 files changed

+151
-0
lines changed

5 files changed

+151
-0
lines changed

adr/2024-09-fork-safety.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
2+
# 2024-09 Implement `Database#discard` to mitigate sqlite's lack of fork safety
3+
4+
## Status
5+
6+
Proposed, seeking feedback and suggestions for other alternative solutions.
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+
The sqlite3-ruby gem will introduce support for `Database#discard` in v2.1.0, which can be called by frameworks like Rails that have made the decision to allow forking while connections are open. This method will close the underlying file descriptors, but will not free any of the memory or call `sqlite3_close` to clean up internal data structures.
21+
22+
23+
## Consequences
24+
25+
The positive consequence is that frameworks like Rails can prevent database file corruption as a result of allowing forks by simply `discard`ing the inherited connections in the child process.
26+
27+
The negative consequence is that, when choosing this option, some memory will be permanently lost (leaked) in the child process.
28+
29+
30+
## Alternatives considered.
31+
32+
### 1. Require applications to close database connections before forking.
33+
34+
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.
35+
36+
### 2. Memory arena
37+
38+
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.
39+
40+
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.
41+
42+
43+
## References
44+
45+
- [Database#discard by flavorjones · Pull Request #558 · sparklemotion/sqlite3-ruby](https://github.com/sparklemotion/sqlite3-ruby/pull/558)
46+
- TODO rails pr implementing sqlite3adapter discard
47+
48+
49+
## Footnotes
50+
51+
[^issue]: [SQLite queue database corruption · Issue #324 · rails/solid_queue](https://github.com/rails/solid_queue/issues/324)
52+
[^repro]: [flavorjones/2024-09-13-sqlite-corruption: Temporary repo, reproduction of sqlite database corruption.](https://github.com/flavorjones/2024-09-13-sqlite-corruption)
53+
[^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_)
54+
[^gemleak]: [Always call sqlite3_finalize in deallocate func by haileys · Pull Request #392 · sparklemotion/sqlite3-ruby](https://github.com/sparklemotion/sqlite3-ruby/pull/392)
55+
[^advice]: [SQLite Forum: Correct way of carrying connections over forked processes](https://sqlite.org/forum/forumpost/1fa07728204567a0a136f442cb1c59e3117da96898b7fa3290b0063ae7f6f012)
56+
[^before_fork]: [SQLite3Adapter: Ensure fork-safety by flavorjones · Pull Request #52931 · rails/rails](https://github.com/rails/rails/pull/52931#issuecomment-2351365601)
57+
[^config]: [SQlite3 Configuration Options](https://www.sqlite.org/c3ref/c_config_covering_index_scan.html)

ext/sqlite3/database.c

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,43 @@ closed_p(VALUE self)
155155
return Qfalse;
156156
}
157157

158+
/* call-seq: db.discard
159+
*
160+
* ⚠ Do not use this method unless you know what you are doing.
161+
*
162+
* This method is intended to be used by frameworks like Rails after a process with open databases
163+
* has forked. It will leak the memory associated with the incompletely-closed connection.
164+
*
165+
* See adr/2024-09-fork-safety.md for more information on why this method exists.
166+
*/
167+
static VALUE
168+
discard(VALUE self)
169+
{
170+
sqlite3RubyPtr ctx;
171+
sqlite3_file *sfile;
172+
int status;
173+
174+
TypedData_Get_Struct(self, sqlite3Ruby, &database_type, ctx);
175+
176+
if (ctx->db) {
177+
status = sqlite3_file_control(ctx->db, NULL, SQLITE_FCNTL_FILE_POINTER, &sfile);
178+
if (status == 0 && sfile->pMethods != NULL) {
179+
sfile->pMethods->xClose(sfile);
180+
}
181+
182+
status = sqlite3_file_control(ctx->db, NULL, SQLITE_FCNTL_JOURNAL_POINTER, &sfile);
183+
if (status == 0 && sfile->pMethods != NULL) {
184+
sfile->pMethods->xClose(sfile);
185+
}
186+
187+
ctx->db = NULL;
188+
189+
rb_iv_set(self, "-aggregators", Qnil);
190+
}
191+
192+
return self;
193+
}
194+
158195
/* call-seq: total_changes
159196
*
160197
* Returns the total number of changes made to this database instance
@@ -890,6 +927,7 @@ init_sqlite3_database(void)
890927
rb_define_method(cSqlite3Database, "collation", collation, 2);
891928
rb_define_method(cSqlite3Database, "close", sqlite3_rb_close, 0);
892929
rb_define_method(cSqlite3Database, "closed?", closed_p, 0);
930+
rb_define_method(cSqlite3Database, "discard", discard, 0);
893931
rb_define_method(cSqlite3Database, "total_changes", total_changes, 0);
894932
rb_define_method(cSqlite3Database, "trace", trace, -1);
895933
rb_define_method(cSqlite3Database, "last_insert_row_id", last_insert_row_id, 0);

test/helper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,10 @@ class TestCase < Minitest::Test
2020
def assert_nothing_raised
2121
yield
2222
end
23+
24+
def i_am_running_in_valgrind
25+
# https://stackoverflow.com/questions/365458/how-can-i-detect-if-a-program-is-running-from-within-valgrind/62364698#62364698
26+
ENV["LD_PRELOAD"] =~ /valgrind|vgpreload/
27+
end
2328
end
2429
end

test/test_database.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,5 +719,36 @@ def test_transaction_returns_block_result
719719
result = @db.transaction { :foo }
720720
assert_equal :foo, result
721721
end
722+
723+
def test_discard_a_connection
724+
skip("Database#discard leaks memory") if i_am_running_in_valgrind
725+
726+
begin
727+
db = SQLite3::Database.new("test.db")
728+
result = db.discard
729+
730+
assert_predicate(db, :closed?)
731+
assert_same(db, result)
732+
733+
assert_nothing_raised do
734+
db.close
735+
end
736+
ensure
737+
FileUtils.rm_f("test.db")
738+
end
739+
end
740+
741+
def test_discard_a_closed_connection
742+
db = SQLite3::Database.new("test.db")
743+
db.close
744+
745+
result = nil
746+
assert_nothing_raised do
747+
result = db.discard
748+
end
749+
assert_same(db, result)
750+
ensure
751+
FileUtils.rm_f("test.db")
752+
end
722753
end
723754
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)