-
Notifications
You must be signed in to change notification settings - Fork 203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open & writable database connections carried across fork() are automatically discarded in the child #558
Conversation
1ffe29e
to
093a76f
Compare
48ea2fa
to
df07939
Compare
@jeremy suggested changing |
0c2ce10
to
760d766
Compare
Agreed. That's a great idea. Make it less weird than exposing a method that basically says: leak memory for me please. |
760d766
to
144a503
Compare
I've rewritten this PR. Diffs from the original:
After conversations with a few folks, I came to the conclusion that a I'll leave this open for a bit if anyone wants to give feedback. |
ef292eb
to
8a08461
Compare
@tenderlove Can I get your 👀 on this before I whack merge |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes sense, but if I understand it correctly the change doesn't prevent corruption, it just emits a warning if someone calls "close" on a database that happened to be opened in another process. Someone could, for example, fork a process then execute queries in the child process thereby causing corruption.
I don't think we should check the PID for every query. Unsure if it's possible, but could we register a callback with Process._fork
for every open database and then issue a warning on fork?
To be clear, I'm fine with the change in this PR. It makes sense.
133b97e
to
c24a3f2
Compare
@tenderlove Thanks for reviewing.
It will prevent corruption for Rails apps (when we also update sqlite3adapter). By not calling
but yes, it doesn't always prevent corruption, I'm just trying to nail the Rails use case here (which will require a PR to Rails as well).
Yes, we could absolutely do that -- just to make sure I understand, you want the warning issued in the parent process when the fork happens, yes? I may push it to a separate PR though. I guess we could also register a callback with Process._fork that would immediately close ("discard") the connection in the child. That might prevent corruption anywhere. Let me play with that. |
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.
c24a3f2
to
05ede01
Compare
c6bc510
to
421b3b9
Compare
421b3b9
to
ad5797b
Compare
f6eb762
to
d16ac2c
Compare
Added a commit that ensures Statements related to a discarded database will raise an exception if people try to use them. |
6d705a8
to
c213875
Compare
- Make sure it's only emitted once per fork. - Try to clarify the warning message.
c213875
to
20025b4
Compare
9d66662
to
7e97204
Compare
Will cut a release candidate today. |
@@ -121,6 +135,7 @@ step(VALUE self) | |||
|
|||
TypedData_Get_Struct(self, sqlite3StmtRuby, &statement_type, ctx); | |||
|
|||
require_open_db(self); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't notice this in the initial review. step
is an extremely hot method, do we actually need to do this? I thought SQLite would already raise an exception if the db is somehow closed out from under it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it does an IV read which is a bit much. If it was a struct member access I think it would be acceptable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will look at other ways to make sure we're protected. This is really just protecting us from segfault if the DB was "discarded" so I can put something in the struct and that should be good enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See #565
Context
In August 2024, Andy Croll opened an issue1 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.2
SQLite is known to not be fork-safe3, 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 statements4 in v2.0.0.
Advice from upstream contributors5 is, essentially: don't fork if you have open database connections. Or, if you have forked, don't call
sqlite3_close
on those connections and thereby leak some amount of memory in the child process. Neither of these options are ideal, see below.Decisions
fork()
will automatically be closed in the child process to mitigate the risk of corrupting the database file.First, the gem will register an "after fork" handler via
Process._fork
that will close any open writable database connections in the child process. This is a best-effort attempt to avoid corruption, but it is not guaranteed to prevent corruption in all cases. Any connections closed by this handler will also emit a warning to let users know what's happening.Second, the sqlite3-ruby gem will store the ID of the process that opened each database connection. If, when a writable database is closed (either explicitly with
Database#close
or implicitly via GC or after-fork callback) the current process ID is different from the original process, then we "discard" the connection."Discard" here means:
Database
object acts "closed", including returningtrue
from#closed?
.sqlite3_close_v2
is not called on the object, because it is unsafe to do so per sqlite instructions3. As a result, some memory will be lost permanently (a one-time "memory leak").Note that readonly databases are being treated as "fork safe" and are not affected by any of these changes.
Consequences
The positive consequence is that we remove a potential cause of database corruption for applications that fork with active sqlite database connections.
The negative consequence is that, for each discarded connection, some memory will be permanently lost (leaked) in the child process.
Alternatives considered.
1. Require applications to close database connections before forking.
This is the advice5 given by the upstream maintainers of sqlite, and so was the first thing we tried to implement in Rails in rails/rails#529316. 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.
2. Memory arena
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.
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 discarded connections turns out to be a large source of pain.
References
Footnotes
Footnotes
SQLite queue database corruption · Issue #324 · rails/solid_queue ↩
flavorjones/2024-09-13-sqlite-corruption: Temporary repo, reproduction of sqlite database corruption. ↩
How To Corrupt An SQLite Database File: §2.6 Carrying an open database connection across a fork() ↩ ↩2
Always call sqlite3_finalize in deallocate func by haileys · Pull Request #392 · sparklemotion/sqlite3-ruby ↩
SQLite Forum: Correct way of carrying connections over forked processes ↩ ↩2
SQLite3Adapter: Ensure fork-safety by flavorjones · Pull Request #52931 · rails/rails ↩