Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

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 from v1.1.3; otherwise changelog diff links for the new release won't resolve correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 10:

<comment>Add the `[1.1.3]` footer compare link and update `[Unreleased]` to compare from `v1.1.3`; otherwise changelog diff links for the new release won't resolve correctly.</comment>

<file context>
@@ -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
</file context>


### 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update changelog reference links for the new 1.1.3 entry.

The new release section was added, but the footer links are still anchored to older versions ([Unreleased] still compares from v0.32.3, and there’s no [1.1.3] compare link). This breaks release diff navigation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 10 - 22, Update the CHANGELOG.md footer links so
the new release header [1.1.3] has its own compare link and the [Unreleased]
compare range is rebased to start from v1.1.3; specifically add a markdown link
reference for [1.1.3] pointing to the compare URL between the previous tag and
v1.1.3, and update the existing [Unreleased] link to compare from v1.1.3 (or the
repository's main release tag) to HEAD/main. Ensure the link labels referenced
in the body ([Unreleased] and [1.1.3]) exactly match the new footer entries.


## [1.1.2] - 2026-05-31

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cortex"
version = "1.1.2"
version = "1.1.3"
edition = "2021"
rust-version = "1.86"
description = "Homelab intelligence platform — syslog/OTLP/Docker log aggregation, fleet awareness, and AI agent coordination over MCP, CLI, and HTTP"
Expand All @@ -27,6 +27,7 @@ tower-http = { version = "0.6", features = ["cors", "limit", "trace"] }

# SQLite
rusqlite = { version = "0.39", features = ["bundled", "vtab", "backup"] }
parking_lot = "0.12"
r2d2 = "0.8"
r2d2_sqlite = "0.33"
scheduled-thread-pool = "0.2"
Expand Down
2 changes: 1 addition & 1 deletion mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"manifest_version": "0.4",
"name": "cortex",
"display_name": "Cortex",
"version": "1.1.2",
"version": "1.1.3",
"description": "Query local cortex SQLite logs through a bundled stdio MCP server.",
"long_description": "cortex packages the existing cortex stdio entrypoint as a local MCP Bundle. It is query-only: it reads the configured SQLite database and does not start syslog listeners, HTTP servers, Docker Compose, REST, or deploy flows.",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/jmagar/cortex",
"source": "github"
},
"version": "1.1.2",
"version": "1.1.3",
"packages": [
{
"registryType": "oci",
Expand Down
1 change: 1 addition & 0 deletions src/app/error_detection/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ pub(crate) fn process_chunk(
}

// --- Write signatures and windows in a single transaction ---
let _write_guard = crate::db::write_lock();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Don't wait on write_lock() while holding this pooled connection. Reacquire the connection for the write transaction so queued writers don't pin pool slots and reduce read availability under contention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/error_detection/scanner.rs, line 186:

<comment>Don't wait on `write_lock()` while holding this pooled connection. Reacquire the connection for the write transaction so queued writers don't pin pool slots and reduce read availability under contention.</comment>

<file context>
@@ -183,6 +183,7 @@ pub(crate) fn process_chunk(
     }
 
     // --- Write signatures and windows in a single transaction ---
+    let _write_guard = crate::db::write_lock();
     let tx = conn.transaction()?;
 
</file context>

let tx = conn.transaction()?;
Comment on lines +186 to 187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/error_detection/scanner.rs` around lines 186 - 187, Currently the
pooled DB connection (conn) is held across the global write lock acquisition
(crate::db::write_lock()), which can exhaust the pool; drop or let conn go out
of scope before calling write_lock(), then after acquiring the write guard
reopen a new connection and start the transaction (create tx) inside the locked
section; specifically move the creation of tx (conn.transaction()) to after
obtaining the write guard and re-initialize conn there so only the active writer
holds a pool slot.


for (hash, group) in &groups {
Expand Down
2 changes: 2 additions & 0 deletions src/app/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Acquire write_lock() before checking out the DB connection in this commit path. Waiting on the mutex while holding a pool slot can exhaust the pool during concurrent ack/unack writes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/service.rs, line 2062:

<comment>Acquire `write_lock()` before checking out the DB connection in this commit path. Waiting on the mutex while holding a pool slot can exhaust the pool during concurrent ack/unack writes.</comment>

<file context>
@@ -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();
             let tx = conn.transaction()?;
             crate::db::error_signatures::record_ack_event(
</file context>

let tx = conn.transaction()?;
Comment on lines 2061 to 2063

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not queue ack/unack commits on the mutex while holding pool slots.

Both commit paths check out a connection before waiting on write_lock(). Under concurrent write load, queued acknowledgements can park on the mutex with pooled connections held, shrinking the pool for readers. Flip the order in both closures.

🐛 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/service.rs` around lines 2061 - 2063, The code currently acquires a
DB connection via pool.get() and then calls crate::db::write_lock(), which can
hold pooled connections while waiting on the global write mutex; change both
commit paths to acquire the write mutex first (call crate::db::write_lock() and
obtain the guard) and only then check out a connection (pool.get()) and call
conn.transaction() so the mutex wait does not hold a pool slot; apply the same
flip at the other occurrence referenced (the closure around lines 2130-2132).

crate::db::error_signatures::record_ack_event(
&tx,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub use models::{
pub use models::{StorageBudgetState, StorageEnforcementOutcome, StorageMetrics, StorageRecovery};
pub use pool::{
backfill_inventory_stats, init_pool, inventory_backfill_complete, read_schema_version_info,
read_schema_version_info_conn, DbPool, SchemaVersionInfo, KNOWN_SCHEMA_VERSION,
read_schema_version_info_conn, write_lock, DbPool, SchemaVersionInfo, KNOWN_SCHEMA_VERSION,
};
pub use queries::{
ai_session_rollup_status, ask_history_sessions, get_error_summary, get_stats,
Expand Down
1 change: 1 addition & 0 deletions src/db/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Take the write lock before pool.get

With the default CORTEX_POOL_SIZE=4, a burst of four writer subsystems can each borrow a pooled connection and then queue on this mutex; the three queued writers hold their connections while waiting, so normal read paths cannot get a connection even though WAL should allow reads during the active writer. This can make MCP/API reads time out under exactly the sustained ingest/maintenance contention this change is meant to fix; acquire write_lock() before pool.get() in the guarded write paths so queued writers do not exhaust the read pool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Acquire the global write lock before pool.get(); current ordering can exhaust the r2d2 pool with waiting writers and block read traffic under write contention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/db/ingest.rs, line 36:

<comment>Acquire the global write lock before `pool.get()`; current ordering can exhaust the r2d2 pool with waiting writers and block read traffic under write contention.</comment>

<file context>
@@ -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();
     let tx = conn.transaction()?;
     insert_logs_batch_in_tx(&tx, entries)?;
</file context>

let tx = conn.transaction()?;
insert_logs_batch_in_tx(&tx, entries)?;
tx.commit()?;
Expand Down
10 changes: 10 additions & 0 deletions src/db/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 _write_guard acquired inside the chunked-DELETE loop is not dropped until the end of the loop body, which includes std::thread::sleep(50ms). This means the process-wide write lock is held for the entire 50ms sleep, preventing the batch writer (and all other write paths) from acquiring it during that window. The PR's stated design intent is to release the lock between chunks so the batch writer can proceed.

Fix: explicitly drop _write_guard before std::thread::sleep(...) in both loops.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/db/maintenance.rs, line 55:

<comment>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 `_write_guard` acquired inside the chunked-DELETE loop is not dropped until the end of the loop body, which includes `std::thread::sleep(50ms)`. This means the process-wide write lock is held for the entire 50ms sleep, preventing the batch writer (and all other write paths) from acquiring it during that window. The PR's stated design intent is to release the lock between chunks so the batch writer can proceed.

Fix: explicitly drop `_write_guard` before `std::thread::sleep(...)` in both loops.</comment>

<file context>
@@ -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();
     conn.execute_batch(&format!("PRAGMA incremental_vacuum({pages});"))?;
     Ok(())
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Acquire write_lock() before self.pool.get() in this transaction path so blocked writers don't hold pooled connections while waiting on the global write mutex.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/db/maintenance.rs, line 55:

<comment>Acquire `write_lock()` before `self.pool.get()` in this transaction path so blocked writers don't hold pooled connections while waiting on the global write mutex.</comment>

<file context>
@@ -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();
     conn.execute_batch(&format!("PRAGMA incremental_vacuum({pages});"))?;
     Ok(())
</file context>

conn.execute_batch(&format!("PRAGMA incremental_vacuum({pages});"))?;
Comment on lines +55 to 56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Take the write mutex before checking out pooled connections on the write-only paths.

These helpers now queue on write_lock() while already holding r2d2 connections. A burst of maintenance writers can therefore fill the pool with idle waiters and block reads. Flip the order at these sites so the pool slot is only taken by the writer that is actually entering SQLite.

🐛 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/maintenance.rs` around lines 55 - 56, The write mutex
(crate::db::write_lock()) must be acquired before checking out r2d2 pooled
connections on write-only paths to avoid queuing while holding a pool slot;
change the order in the affected helpers so you call crate::db::write_lock()
first, then obtain the pooled connection (the code that currently creates or
checks out conn), and only after that call conn.execute_batch or other write
methods (refer to the current occurrences using the write_lock() binding and
conn.execute_batch/conn.prepare/etc. at the sites around the existing snippets
and the other listed locations).

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(())
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Split the preflight connection from the locked write phase.

delete_oldest_logs_chunk and checkpoint_wal_and_incremental_vacuum both do read/checkpoint work on an already checked-out connection and then wait on write_lock() for the mutating step. That still lets contending maintenance tasks pin pool slots while idle. Drop the preflight connection and reacquire one inside the locked section for the actual DELETE/vacuum.

Also applies to: 809-810

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/maintenance.rs` around lines 636 - 637, Both delete_oldest_logs_chunk
and checkpoint_wal_and_incremental_vacuum currently obtain a DB connection
(conn) before calling crate::db::write_lock(), which holds a pool slot while
waiting; change the flow so any preflight/read/checkpoint work uses a transient
connection, then drop that connection before calling write_lock(), and after
acquiring the write lock call the connection-acquisition path again to get a
fresh conn for the mutating step (the DELETE/vacuum execute that assigns
deleted_rows). In short: do not hold conn across write_lock(); perform
reads/checkpoint with one connection, release it, call write_lock(), then open a
new conn to run the execute/delete/vacuum inside the locked section (also apply
same change to the other occurrence around the 809-810 area).

"DELETE FROM logs \
WHERE id IN (SELECT id FROM logs ORDER BY received_at ASC, id ASC LIMIT ?1)",
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions src/db/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ use crate::config::StorageConfig;

pub type DbPool = Pool<SqliteConnectionManager>;

/// Process-wide SQLite **write serialization** lock.
///
/// SQLite permits only one writer at a time, but cortex runs an r2d2 pool of several
/// connections with multiple concurrent writer subsystems (syslog/docker ingest,
/// heartbeat, notifications, AI index, retention maintenance). Without serialization
/// these race SQLite's single write lock, exceed `busy_timeout`, and surface as
/// `database is locked` — dropping log batches. Every write transaction acquires this
/// guard so writers queue in-process instead of colliding at the SQLite layer; reads
/// stay concurrent on the pool (WAL allows many readers). Reentrant so a write path that
/// nests guarded helpers on a single thread cannot deadlock.
pub fn write_lock() -> parking_lot::ReentrantMutexGuard<'static, ()> {
static WRITE_LOCK: parking_lot::ReentrantMutex<()> = parking_lot::ReentrantMutex::new(());
WRITE_LOCK.lock()
}

pub const KNOWN_SCHEMA_VERSION: i64 = 20;

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
Expand Down
1 change: 1 addition & 0 deletions src/heartbeat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ fn insert_heartbeat(
) -> anyhow::Result<HeartbeatIngestResponse> {
let received_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut conn = pool.get()?;
let _write_guard = crate::db::write_lock();
let tx = conn.transaction()?;
Comment on lines 150 to 153
let metadata_json = heartbeat_metadata_json(&request)?;

Expand Down
1 change: 1 addition & 0 deletions src/notifications/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Serialize the auto-committed dispatcher writes too.

db_tx is covered now, but this file still routes outbox_mark_dropped(...) and outbox_schedule_retry(...) through db_read, which executes them without write_lock(). Those paths can still hit SQLITE_BUSY, so the dispatcher is not actually fully covered by the new serialization scheme. Add a dedicated single-statement write helper and move those call sites onto it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/notifications/dispatcher.rs` around lines 81 - 83, The dispatcher still
calls outbox_mark_dropped(...) and outbox_schedule_retry(...) via db_read,
causing unprotected writes and potential SQLITE_BUSY; add a single-statement
write helper (e.g., db_write_single<F, T>(pool: &Pool, f: F) -> Result<T, E>)
that acquires crate::db::write_lock(), gets a connection, performs a
one-statement transaction or direct exec, and returns the result, then replace
the db_read call sites in dispatcher.rs for outbox_mark_dropped and
outbox_schedule_retry with this new helper so those auto-committed writes are
serialized under write_lock() just like db_tx paths.

f(&tx)?;
tx.commit()?;
Expand Down
2 changes: 2 additions & 0 deletions src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ fn flush_chunk(
if batch.is_empty() {
if let Some(file_metadata) = completion_metadata {
let mut conn = pool.get()?;
let _write_guard = crate::db::write_lock();
let tx = conn.transaction()?;
checkpoint::update_source_metadata_in_tx(&tx, source_id, file_metadata)?;
tx.commit()?;
Expand All @@ -776,6 +777,7 @@ fn flush_chunk(
}

let mut conn = pool.get()?;
let _write_guard = crate::db::write_lock();
let tx = conn.transaction()?;
let claimed = checkpoint::claim_imports_in_tx(&tx, source_id, imports)?;
let mut claimed_batch = Vec::with_capacity(batch.len());
Expand Down
2 changes: 2 additions & 0 deletions src/scanner/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Acquire the global write lock before checking out checkpoint connections.

Both transaction paths wait on write_lock() while already holding pooled connections. Under concurrent write traffic, that can fill the pool with idle waiters and stall read requests. Take the mutex first, then call self.pool.get().

🐛 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/scanner/checkpoint.rs` around lines 139 - 141, The code currently calls
self.pool.get() then acquires the global write lock via crate::db::write_lock(),
which can cause pooled connections to be held while waiting for the mutex; move
the write_lock() acquisition to before calling self.pool.get() so the mutex is
taken first, then checkout the connection. Update both occurrences around the
transaction creation (the block using let mut conn = self.pool.get()?; let
_write_guard = crate::db::write_lock(); let tx = conn.transaction()?) and the
second similar block so the order is let _write_guard = crate::db::write_lock();
let mut conn = self.pool.get()?; let tx = conn.transaction()?; and limit the
guard scope to only cover the critical section as needed.

tx.execute(
"DELETE FROM transcript_import_records WHERE source_id = ?1",
Expand Down Expand Up @@ -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(
Expand Down
Loading