Skip to main content

max / goingson

Data/DB: busy_timeout, email pagination clamp, stop-timer double-count guard - init_pool: set busy_timeout(5s). WAL allows concurrent readers + one writer, so a second writer (backup tick vs sync pull) would otherwise get an immediate SQLITE_BUSY; wait briefly for the lock instead. - email_repo.list_threaded: clamp limit/offset before binding (negative LIMIT is unbounded in SQLite = whole-mailbox load), matching task_repo/search_repo. - stop_timer: close the session with `WHERE ... AND ended_at IS NULL` and only add to the task's actual_minutes cache when that UPDATE affected a row, so two concurrent stops can't double-count (the SELECT ran outside the txn). Closes the Data/DB cold-spot minors; clippy clean, repo tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 02:29 UTC
Commit: cf8dee10ec85a0956dd38d85257f4dbb22912bfc
Parent: c2d3f43
3 files changed, +25 insertions, -7 deletions
@@ -28,7 +28,12 @@ pub async fn init_pool(database_path: Option<&str>) -> Result<SqlitePool, sqlx::
28 28 let options = SqliteConnectOptions::from_str(&format!("sqlite:{}", path))?
29 29 .create_if_missing(true)
30 30 .foreign_keys(true)
31 - .journal_mode(SqliteJournalMode::Wal);
31 + .journal_mode(SqliteJournalMode::Wal)
32 + // WAL allows concurrent readers + one writer; a second writer (e.g. a
33 + // backup tick racing a sync pull) would otherwise get SQLITE_BUSY
34 + // immediately. Wait briefly for the lock instead. acquire_timeout only
35 + // bounds pool checkout, not lock contention inside a held connection.
36 + .busy_timeout(Duration::from_secs(5));
32 37
33 38 SqlitePoolOptions::new()
34 39 .max_connections(5)
@@ -157,8 +157,12 @@ impl EmailRepository for SqliteEmailRepository {
157 157 let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" };
158 158 let folder_filter = folder.map(|_| "AND e.source_folder = ?").unwrap_or("");
159 159 let label_filter = label.map(|_| "AND EXISTS (SELECT 1 FROM json_each(e.labels) j WHERE j.value = ?)").unwrap_or("");
160 - let offset_val = offset.unwrap_or(0);
161 - let limit_val = limit.unwrap_or(50);
160 + // Defense-in-depth: clamp before binding. A negative LIMIT means
161 + // unbounded in SQLite (would load the whole mailbox); a negative OFFSET
162 + // is ignored. Matches task_repo/search_repo.
163 + const MAX_PAGE_LIMIT: i64 = 1000;
164 + let offset_val = offset.unwrap_or(0).max(0);
165 + let limit_val = limit.unwrap_or(50).clamp(0, MAX_PAGE_LIMIT);
162 166
163 167 // Query 1: Get total thread count
164 168 let count_sql = format!(
@@ -165,9 +165,12 @@ pub(crate) async fn stop_timer(
165 165
166 166 let mut tx = pool.begin().await.map_err(CoreError::database)?;
167 167
168 - // Update the session
169 - sqlx::query(
170 - "UPDATE time_sessions SET ended_at = ?, duration_minutes = ? WHERE id = ?"
168 + // Close the session only if it is still active. The active-session SELECT
169 + // above ran outside this transaction, so two concurrent stops could both
170 + // see it open; gating on `ended_at IS NULL` means the loser updates 0 rows
171 + // and we skip the actual_minutes increment, preventing a double-count.
172 + let closed = sqlx::query(
173 + "UPDATE time_sessions SET ended_at = ?, duration_minutes = ? WHERE id = ? AND ended_at IS NULL"
171 174 )
172 175 .bind(&now_str)
173 176 .bind(duration)
@@ -176,7 +179,13 @@ pub(crate) async fn stop_timer(
176 179 .await
177 180 .map_err(CoreError::database)?;
178 181
179 - // Update the task's actual_minutes cache
182 + if closed.rows_affected() == 0 {
183 + // Another stop already closed this session; nothing to add.
184 + tx.rollback().await.ok();
185 + return Ok(None);
186 + }
187 +
188 + // Update the task's actual_minutes cache (only when we closed the session).
180 189 sqlx::query(
181 190 "UPDATE tasks SET actual_minutes = actual_minutes + ? WHERE id = ?"
182 191 )