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>
3 files changed,
+25 insertions,
-7 deletions
| 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 |
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 |
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 |
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 |
|
)
|