-
Notifications
You must be signed in to change notification settings - Fork 2
fix(db): serialize SQLite writes to eliminate 'database is locked' (v1.1.3) #64
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |
|
|
||
| ## [Unreleased] | ||
|
|
||
| ## [1.1.3] - 2026-06-01 | ||
|
|
||
| ### Fixed | ||
|
|
||
| - **Serialize SQLite writes to eliminate `database is locked` errors and dropped log batches.** | ||
| cortex ran an r2d2 pool of 4 write-capable connections while 6 writer subsystems | ||
| (syslog/docker ingest, heartbeat, notifications, AI index, retention maintenance) | ||
| raced SQLite's single write lock, exceeding `busy_timeout` (~1000+ lock errors/day and | ||
| silently discarded log batches). Added a process-wide reentrant write-serialization | ||
| lock (`db::write_lock()`) acquired by every write transaction and standalone mutating | ||
| statement (purge DELETEs, VACUUM, incremental_vacuum, merge). Reads stay concurrent on | ||
| the pool (WAL); PASSIVE checkpoints are left unguarded. | ||
|
|
||
|
Comment on lines
+10
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update changelog reference links for the new The new release section was added, but the footer links are still anchored to older versions ( 🤖 Prompt for AI Agents |
||
|
|
||
| ## [1.1.2] - 2026-05-31 | ||
|
|
||
| ### Fixed | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -183,6 +183,7 @@ pub(crate) fn process_chunk( | |
| } | ||
|
|
||
| // --- Write signatures and windows in a single transaction --- | ||
| let _write_guard = crate::db::write_lock(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Don't wait on Prompt for AI agents |
||
| let tx = conn.transaction()?; | ||
|
Comment on lines
+186
to
187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Release the read connection before waiting on the global write lock. Because Line 105 keeps the pooled connection alive through the fetch/grouping phase, this new lock is acquired while already holding a pool slot. Under concurrent scan/ingest activity, queued writers can sit on multiple idle connections and block readers despite WAL. Reopen the connection under the locked section so only the active writer holds a pool slot. ♻️ Suggested shape- let _write_guard = crate::db::write_lock();
- let tx = conn.transaction()?;
+ drop(conn);
+ let _write_guard = crate::db::write_lock();
+ let mut conn = pool.get()?;
+ let tx = conn.transaction()?;🤖 Prompt for AI Agents |
||
|
|
||
| for (hash, group) in &groups { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2059,6 +2059,7 @@ impl CortexService { | |
| let hash_clone = hash.clone(); | ||
| self.run_db("ack_error.commit", move |pool| { | ||
| let mut conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Acquire Prompt for AI agents |
||
| let tx = conn.transaction()?; | ||
|
Comment on lines
2061
to
2063
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not queue ack/unack commits on the mutex while holding pool slots. Both commit paths check out a connection before waiting on 🐛 Minimal change- let mut conn = pool.get()?;
- let _write_guard = crate::db::write_lock();
+ let _write_guard = crate::db::write_lock();
+ let mut conn = pool.get()?;Also applies to: 2130-2132 🤖 Prompt for AI Agents |
||
| crate::db::error_signatures::record_ack_event( | ||
| &tx, | ||
|
|
@@ -2127,6 +2128,7 @@ impl CortexService { | |
| let hash_clone = hash.clone(); | ||
| self.run_db("unack_error.commit", move |pool| { | ||
| let mut conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let tx = conn.transaction()?; | ||
| crate::db::error_signatures::record_ack_event( | ||
| &tx, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ pub fn insert_logs_batch(pool: &DbPool, entries: &[LogBatchEntry]) -> Result<usi | |
|
|
||
| fn insert_logs_batch_once(pool: &DbPool, entries: &[LogBatchEntry]) -> Result<usize> { | ||
| let mut conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the default Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Acquire the global write lock before Prompt for AI agents |
||
| let tx = conn.transaction()?; | ||
| insert_logs_batch_in_tx(&tx, entries)?; | ||
| tx.commit()?; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,12 +52,14 @@ pub fn db_wal_checkpoint(pool: &DbPool, mode: &str) -> Result<(i64, i64, i64)> { | |
|
|
||
| pub fn db_incremental_vacuum(pool: &DbPool, pages: u32) -> Result<()> { | ||
| let conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Write lock held across std::thread::sleep in purge_old_logs and purge_by_tag_window, blocking other writers during the inter-chunk delay. The Fix: explicitly drop Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Acquire Prompt for AI agents |
||
| conn.execute_batch(&format!("PRAGMA incremental_vacuum({pages});"))?; | ||
|
Comment on lines
+55
to
56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Take the write mutex before checking out pooled connections on the write-only paths. These helpers now queue on 🐛 Minimal change- let conn = pool.get()?;
- let _write_guard = crate::db::write_lock();
+ let _write_guard = crate::db::write_lock();
+ let conn = pool.get()?;Also applies to: 62-63, 308-309, 412-413, 529-530, 671-672, 737-738, 768-769 🤖 Prompt for AI Agents |
||
| Ok(()) | ||
| } | ||
|
|
||
| pub fn db_full_vacuum(pool: &DbPool) -> Result<()> { | ||
| let conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| conn.execute_batch("VACUUM;")?; | ||
| Ok(()) | ||
| } | ||
|
|
@@ -303,6 +305,7 @@ fn fts_incremental_merge(pool: &DbPool, deleted_rows: usize, merge_pages: u32) { | |
| for i in 0..iterations { | ||
| match pool.get() { | ||
| Ok(conn) => { | ||
| let _write_guard = crate::db::write_lock(); | ||
| match conn.execute_batch(&merge_stmt) { | ||
| Ok(()) => { | ||
| consecutive_failures = 0; | ||
|
Comment on lines
307
to
311
Comment on lines
307
to
311
|
||
|
|
@@ -406,6 +409,7 @@ pub fn purge_old_logs(pool: &DbPool, retention_days: u32, fts_merge_pages: u32) | |
| let mut total_deleted: usize = 0; | ||
| loop { | ||
| let conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let chunk = conn.execute( | ||
| "DELETE FROM logs WHERE id IN ( | ||
|
Comment on lines
410
to
414
Comment on lines
410
to
414
|
||
| SELECT id FROM logs | ||
|
|
@@ -522,6 +526,7 @@ pub fn purge_by_tag_window( | |
| let mut total_deleted: usize = 0; | ||
| loop { | ||
| let conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let chunk = conn.execute( | ||
| "DELETE FROM logs WHERE id IN ( | ||
|
Comment on lines
527
to
531
Comment on lines
527
to
531
|
||
| SELECT id FROM logs | ||
|
|
@@ -628,6 +633,7 @@ fn delete_oldest_logs_chunk(pool: &DbPool, chunk_size: usize) -> Result<DeletedC | |
|
|
||
| // Delete the oldest chunk using a subquery — O(1) SQL string size regardless | ||
| // of chunk_size, no expression depth issues. | ||
| let _write_guard = crate::db::write_lock(); | ||
| let deleted_rows = conn.execute( | ||
|
Comment on lines
+636
to
637
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Split the preflight connection from the locked write phase.
Also applies to: 809-810 🤖 Prompt for AI Agents |
||
| "DELETE FROM logs \ | ||
| WHERE id IN (SELECT id FROM logs ORDER BY received_at ASC, id ASC LIMIT ?1)", | ||
|
|
@@ -662,6 +668,7 @@ fn delete_heartbeat_chunk_where( | |
| chunk_size: usize, | ||
| ) -> Result<usize> { | ||
| let mut conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let tx = conn.transaction()?; | ||
| tx.execute_batch( | ||
| "CREATE TEMP TABLE IF NOT EXISTS temp_heartbeat_delete_ids ( | ||
|
|
@@ -727,6 +734,7 @@ fn delete_orphan_heartbeat_children(pool: &DbPool) -> Result<usize> { | |
| let conn = pool.get()?; | ||
| let mut total_deleted = 0usize; | ||
| for table in HEARTBEAT_CHILD_TABLES { | ||
| let _write_guard = crate::db::write_lock(); | ||
| let deleted = conn.execute( | ||
| &format!( | ||
| "DELETE FROM {table} | ||
|
|
@@ -757,6 +765,7 @@ fn reconcile_hosts(pool: &DbPool, hostnames: &[String]) -> Result<()> { | |
| } | ||
|
|
||
| let mut conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let tx = conn.transaction()?; | ||
| for hostname in hostnames { | ||
| // One query: count + timestamp bounds in a single pass over the index. | ||
|
|
@@ -797,6 +806,7 @@ fn checkpoint_wal_and_incremental_vacuum(pool: &DbPool) -> Result<()> { | |
| } else { | ||
| tracing::debug!("WAL checkpoint completed"); | ||
| } | ||
| let _write_guard = crate::db::write_lock(); | ||
| if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum(1000);") { | ||
| tracing::warn!(error = %e, "incremental vacuum skipped (non-fatal)"); | ||
| } else { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,6 +79,7 @@ where | |
| let exec_start = Instant::now(); | ||
| let join_result = tokio::task::spawn_blocking(move || -> Result<()> { | ||
| let mut conn = pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let tx = conn.transaction()?; | ||
|
Comment on lines
81
to
83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Serialize the auto-committed dispatcher writes too.
🤖 Prompt for AI Agents |
||
| f(&tx)?; | ||
| tx.commit()?; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,7 @@ impl<'a> CheckpointStore<'a> { | |
|
|
||
| pub fn reset_source(&self, source_id: i64, canonical_path: &str) -> Result<()> { | ||
| let mut conn = self.pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let tx = conn.transaction()?; | ||
|
Comment on lines
139
to
141
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acquire the global write lock before checking out checkpoint connections. Both transaction paths wait on 🐛 Minimal change- let mut conn = self.pool.get()?;
- let _write_guard = crate::db::write_lock();
+ let _write_guard = crate::db::write_lock();
+ let mut conn = self.pool.get()?;Also applies to: 307-309 🤖 Prompt for AI Agents |
||
| tx.execute( | ||
| "DELETE FROM transcript_import_records WHERE source_id = ?1", | ||
|
|
@@ -304,6 +305,7 @@ impl<'a> CheckpointStore<'a> { | |
| } | ||
|
|
||
| let mut conn = self.pool.get()?; | ||
| let _write_guard = crate::db::write_lock(); | ||
| let tx = conn.transaction()?; | ||
| for checkpoint in &checkpoints { | ||
| let source_id: i64 = tx.query_row( | ||
|
|
||
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.
P3: Add the
[1.1.3]footer compare link and update[Unreleased]to compare fromv1.1.3; otherwise changelog diff links for the new release won't resolve correctly.Prompt for AI agents