-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(sqlite): add WAL hook support #4013
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
base: main
Are you sure you want to change the base?
Conversation
e49de40
to
ac2d868
Compare
ac2d868
to
a5b3a33
Compare
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'm amenable to this change, but it seems like a more thought needs to be put into this API.
The docs aren't very clear on this, but my interpretation suggests that sqlite3_wal_hook()
disables the default automatic checkpointing:
https://sqlite.org/c3ref/wal_hook.html
A single database handle may have at most a single write-ahead log callback registered at one time. Calling sqlite3_wal_hook() replaces any previously registered write-ahead log callback. The return value is a copy of the third parameter from the previous call, if any, or 0. Note that the sqlite3_wal_autocheckpoint() interface and the wal_autocheckpoint pragma both invoke sqlite3_wal_hook() and will overwrite any prior sqlite3_wal_hook() settings.
https://sqlite.org/c3ref/wal_autocheckpoint.html
Every new database connection defaults to having the auto-checkpoint enabled with a threshold of 1000 or SQLITE_DEFAULT_WAL_AUTOCHECKPOINT pages. The use of this interface is only necessary if the default setting is found to be suboptimal for a particular application.
We can confirm this by finding the sqlite3_wal_autocheckpoint
call in the openDatabase()
function, the implementation of sqlite3_open*()
: https://github.com/sqlite/sqlite/blob/7fa9e45746f16af409922f9125f375b400dff840/src/main.c#L3610
So ironically, setting this hook as-implemented would cause the exact thing you're worried about--the WAL file growing without bound--because it would never checkpoint otherwise.
After setting this hook, the user must explicitly call sqlite3_wal_checkpoint_v2()
at some point in order for checkpointing to actually occur.
We should design this hook in a way that the user cannot forget to call the checkpoint function. I'm thinking the return type should force them to choose whether to checkpoint or not, and then we invoke sqlite3_wal_checkpoint_v2
on their behalf.
This can be as simple as an enum:
pub enum SqliteWalHookResult {
Continue,
Checkpoint(SqliteCheckpoint),
}
pub enum SqliteCheckpoint {
Passive,
Full,
Restart,
Truncate,
}
Or perhaps instead of a bespoke SqliteWalHookResult
enum, we could use ControlFlow<SqliteCheckpoint>
. That's potentially more flexible since it can do stuff like integrate with the ?
operator.
We also need to be careful exposing remove_wal_hook
because it appears that just deleting the hook won't restore the default behavior. I think we should only support replacing the hook with another callback, or calling sqlite3_wal_autocheckpoint(db, N)
to restore the auto-checkpointing behavior.
Oof. The docs don't state this at all imo. But now it makes sense why Also thanks for reporting upstream. The interfaces you propose sound good in principle, but the user would need to make a decision about WAL truncation, which can be non-trivial. The main issue is that all checkpoint types except for "passive" block until all readers of a state before the most recent snapshot are done. While this is going on new writers are blocked. Depending on other settings and usage patterns this can actually cause deadlocks (if writes are done while holding a read transaction). |
Thanks for filing the documentation bug upstream. This does indeed seem to be problematic. I like your API proposals, but the checkpointing decision is a tricky one, because you need to do a truncate checkpoint at some point, but that's a complex decision. Mainly because (non-passive) checkpoint completion is blocked on current readers and blocks new writers, so it can fairly easily lead to deadlocks (if some context holds a read transaction while starting a write transaction). I don't know how the autocheckpointer implements the decision making exactly, but it does seem to take readers into account when deciding whether a truncate checkpoint should be done.. |
https://sqlite.org/wal.html#concurrency
So it's not that the checkpointer occasionally decides to do a However, that seems really dependent on access patterns. If you're constantly inserting into one table and then reading from it at very high contention, it seems like it could cause the WAL to grow without bound. I don't see a way around having to take an exclusive lock now and then. I'm honestly not sure why they didn't implement the WAL file as a circular buffer. It seems like all the space preceding the checkpoint marker is just wasted until the file resets. Probably because growing a circular buffer is a lot more complicated. Having to copy frames around is a non-starter, so at a certain point you'd have to implement it more like a memory heap. At the end of the day, though, SQLite is not designed for every use-case. If you're truly concerned about this, it could be time to consider a different DBMS. |
Another possibility I had in mind was something like this: pub struct SqliteWalHookContext<'a> {
handle: NonNull<sqlite3>,
db: &'a str,
num_pages: c_int,
}
impl SqliteWalHookContext<'a> {
pub fn num_pages(&self) -> c_int { self.num_pages }
pub fn database(&self) -> &str { self.db }
// Doesn't consume `self` so the checkpoint may be called again
pub fn checkpoint(&mut self, kind: SqliteCheckpointKind) -> Result<SqliteCheckpointResult, SqliteError> {
// ...
}
pub fn ignore(&mut) -> SqliteCheckpointResult { ... }
}
// Cannot be constructed manually
pub struct SqliteCheckpointResult {
log_size_frames: c_int,
checkpointed_frames: c_int
} Then require the callback to return Because |
I thought about this more, and I think a more idiomatic solution would probably be to just have a type marked pub SqliteWalStatus<'a> {
pub db: &'a str,
pub wal_size_frames: u32,
}
#[must_use = "setting a WAL hook on a SQLite database disables automatic checkpointing; see type docs for details"]
pub struct SqliteCheckpointer<'a> {
handle: NonNull<sqlite3>,
_lifetime: PhantomData<&'a mut ()>,
}
impl<'a> SqliteCheckpointer<'a> {
// Can be called multiple times
pub fn checkpoint(&mut self, kind: SqliteCheckpointKind) -> Result<(), SqliteError> { ... }
pub fn do_nothing(self) {
drop(self)
}
} and the WAL hook signature is This way, if the user sets the hook but ignores the checkpointer, the |
Add wal hook suppport.
Does your PR solve an issue?
Fixes #4012
Is this a breaking change?
No. The PR only adds a public method.