Skip to main content

sqlite_graphrag/storage/
utils.rs

1//! Storage utility helpers shared across the storage sub-modules.
2
3use crate::constants::{MAX_SQLITE_BUSY_RETRIES, SQLITE_BUSY_BASE_DELAY_MS};
4use crate::errors::AppError;
5use rusqlite::ErrorCode;
6use std::thread;
7use std::time::Duration;
8
9/// Resolved SQLITE_BUSY retry budget: XDG `db.busy_retries` > factory default.
10///
11/// Clamped to at least 1 so a zero config cannot spin-zero or panic.
12fn resolved_busy_retries() -> u32 {
13    crate::runtime_config::db_busy_retries(MAX_SQLITE_BUSY_RETRIES).max(1)
14}
15
16/// Resolved base delay for the first busy retry: XDG `db.busy_base_delay_ms`.
17fn resolved_busy_base_delay_ms() -> u64 {
18    crate::runtime_config::db_busy_base_delay_ms(SQLITE_BUSY_BASE_DELAY_MS).max(1)
19}
20
21/// Returns `true` when `err` wraps an `SQLITE_BUSY` (or `SQLITE_LOCKED`)
22/// condition reported by rusqlite.
23///
24/// Both `SQLITE_BUSY` (`ErrorCode::DatabaseBusy`) and `SQLITE_LOCKED`
25/// (`ErrorCode::DatabaseLocked`) indicate that the write cannot proceed
26/// immediately due to WAL concurrency.  We treat both as transient and
27/// eligible for retry.
28pub fn is_sqlite_busy(err: &AppError) -> bool {
29    match err {
30        AppError::Database(rusqlite::Error::SqliteFailure(e, _)) => {
31            e.code == ErrorCode::DatabaseBusy || e.code == ErrorCode::DatabaseLocked
32        }
33        _ => false,
34    }
35}
36
37/// Executes `op` up to the resolved busy-retry budget with exponential
38/// backoff whenever the operation fails with `SQLITE_BUSY` / `SQLITE_LOCKED`.
39///
40/// Policy (GAP-SG-87): XDG `db.busy_retries` / `db.busy_base_delay_ms` via
41/// [`crate::runtime_config`], falling back to [`MAX_SQLITE_BUSY_RETRIES`] and
42/// [`SQLITE_BUSY_BASE_DELAY_MS`]. Delay schedule (base = resolved base ms):
43/// attempt *n* → `base * 2^n` with half-jitter in `[base/2, base)`.
44///
45/// After all retries are exhausted the last `SQLITE_BUSY` error is converted
46/// to [`AppError::DbBusy`] so callers can route on exit-code `15`.
47pub fn with_busy_retry<T, F>(op: F) -> Result<T, AppError>
48where
49    F: Fn() -> Result<T, AppError>,
50{
51    let max_retries = resolved_busy_retries();
52    let base_delay_ms = resolved_busy_base_delay_ms();
53    for attempt in 0..max_retries {
54        match op() {
55            Ok(v) => return Ok(v),
56            Err(e) if is_sqlite_busy(&e) => {
57                if crate::retry::is_kill_switch_active() {
58                    tracing::warn!(target: "storage", "retry.disable is set, propagating SQLITE_BUSY immediately");
59                    return Err(e);
60                }
61                // Saturating shift: attempt is bounded by max_retries (u32, small).
62                let shift = attempt.min(63);
63                let base_ms = base_delay_ms.saturating_mul(1u64 << shift);
64                let half = base_ms / 2;
65                let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
66                let delay_ms = half + jitter;
67                tracing::debug!(
68                    target: "storage",
69                    attempt = attempt + 1,
70                    attempt_max = max_retries,
71                    delay_ms,
72                    "SQLITE_BUSY retry with half-jitter"
73                );
74                thread::sleep(Duration::from_millis(delay_ms));
75            }
76            Err(other) => return Err(other),
77        }
78    }
79
80    tracing::error!(
81        target: "storage",
82        retries = max_retries,
83        "SQLITE_BUSY exhausted all retries"
84    );
85    Err(AppError::DbBusy(format!(
86        "SQLITE_BUSY after {max_retries} retries"
87    )))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::sync::atomic::{AtomicU32, Ordering};
94    use std::sync::Arc;
95
96    /// Helper that builds a fake `AppError::Database` wrapping
97    /// `SQLITE_BUSY` (error code 5) so that `is_sqlite_busy` can be tested
98    /// without needing a live SQLite connection.
99    fn make_busy_error() -> AppError {
100        // rusqlite::Error::SqliteFailure requires a `ffi::Error` + optional msg.
101        // We construct it via the public `rusqlite::ffi` interface.
102        let ffi_err = rusqlite::ffi::Error {
103            code: ErrorCode::DatabaseBusy,
104            extended_code: 5,
105        };
106        AppError::Database(rusqlite::Error::SqliteFailure(ffi_err, None))
107    }
108
109    fn make_locked_error() -> AppError {
110        let ffi_err = rusqlite::ffi::Error {
111            code: ErrorCode::DatabaseLocked,
112            extended_code: 6,
113        };
114        AppError::Database(rusqlite::Error::SqliteFailure(ffi_err, None))
115    }
116
117    #[test]
118    fn is_sqlite_busy_detects_database_busy() {
119        assert!(is_sqlite_busy(&make_busy_error()));
120    }
121
122    #[test]
123    fn is_sqlite_busy_detects_database_locked() {
124        assert!(is_sqlite_busy(&make_locked_error()));
125    }
126
127    #[test]
128    fn is_sqlite_busy_rejects_other_errors() {
129        let err = AppError::Validation("invalid field".into());
130        assert!(!is_sqlite_busy(&err));
131    }
132
133    #[test]
134    fn with_busy_retry_propagates_non_busy_error() {
135        let calls = Arc::new(AtomicU32::new(0));
136        let calls_clone = Arc::clone(&calls);
137
138        let result: Result<(), AppError> = with_busy_retry(|| {
139            calls_clone.fetch_add(1, Ordering::SeqCst);
140            Err(AppError::Validation("campo x".into()))
141        });
142
143        // Non-busy errors must propagate immediately without retrying.
144        assert_eq!(calls.load(Ordering::SeqCst), 1);
145        assert!(matches!(result, Err(AppError::Validation(_))));
146    }
147
148    #[test]
149    fn with_busy_retry_succeeds_on_third_attempt() {
150        let calls = Arc::new(AtomicU32::new(0));
151        let calls_clone = Arc::clone(&calls);
152
153        // Fail twice with SQLITE_BUSY, succeed on the third call.
154        let result = with_busy_retry(|| {
155            let n = calls_clone.fetch_add(1, Ordering::SeqCst);
156            if n < 2 {
157                Err(make_busy_error())
158            } else {
159                Ok(())
160            }
161        });
162
163        assert_eq!(calls.load(Ordering::SeqCst), 3);
164        assert!(result.is_ok(), "expected Ok after 3rd attempt");
165    }
166
167    #[test]
168    fn busy_retry_jitter_in_range() {
169        // Verify that the half-jitter formula stays within [base/2, base) for attempt=2.
170        // attempt=2 → base_ms = SQLITE_BUSY_BASE_DELAY_MS * 4; half = base_ms/2.
171        // We call fastrand::u64 indirectly through with_busy_retry by observing that the
172        // function completes; direct delay bounds are tested via the formula invariant.
173        let base_ms = SQLITE_BUSY_BASE_DELAY_MS * (1u64 << 2); // attempt=2
174        let half = base_ms / 2;
175        for _ in 0..100 {
176            let jitter = fastrand::u64(0..half);
177            let delay_ms = half + jitter;
178            assert!(
179                delay_ms >= half && delay_ms < base_ms,
180                "delay_ms {delay_ms} out of [{half}, {base_ms})"
181            );
182        }
183    }
184
185    #[test]
186    fn with_busy_retry_returns_db_busy_after_all_retries() {
187        let calls = Arc::new(AtomicU32::new(0));
188        let calls_clone = Arc::clone(&calls);
189
190        let result: Result<(), AppError> = with_busy_retry(|| {
191            calls_clone.fetch_add(1, Ordering::SeqCst);
192            Err(make_busy_error())
193        });
194
195        let expected = resolved_busy_retries();
196        assert_eq!(
197            calls.load(Ordering::SeqCst),
198            expected,
199            "must attempt exactly the resolved busy-retry budget"
200        );
201        assert!(
202            matches!(result, Err(AppError::DbBusy(_))),
203            "must convert to DbBusy after exhausting retries"
204        );
205    }
206
207    /// GAP-SG-76/v1.1.00 fix: `with_busy_retry` was generalised from
208    /// `Result<(), AppError>` to `Result<T, AppError>` so the enrich dequeue
209    /// loops (which claim a non-unit `DequeueOutcome`) can reuse the bounded
210    /// helper instead of an unbounded `loop { ... continue; }` on
211    /// `SQLITE_BUSY`. This proves the generic path (a) still returns the
212    /// caller-typed `Ok(v)` on success and (b) is bounded (never spins
213    /// forever) when the wrapped operation always reports `SQLITE_BUSY`.
214    #[test]
215    fn with_busy_retry_is_generic_over_return_type() {
216        // (a) success path threads a non-unit T through unchanged.
217        let ok: Result<i64, AppError> = with_busy_retry(|| Ok(42i64));
218        assert_eq!(ok.unwrap(), 42);
219
220        // (b) exhaustion path is bounded for a non-unit T: exactly the
221        // resolved retry budget attempts, then Err(DbBusy), never an
222        // infinite retry loop.
223        let calls = Arc::new(AtomicU32::new(0));
224        let calls_clone = Arc::clone(&calls);
225        let result: Result<i64, AppError> = with_busy_retry(|| {
226            calls_clone.fetch_add(1, Ordering::SeqCst);
227            Err(make_busy_error())
228        });
229        assert_eq!(calls.load(Ordering::SeqCst), resolved_busy_retries());
230        assert!(matches!(result, Err(AppError::DbBusy(_))));
231    }
232
233    /// GAP-SG-87: factory defaults remain active when no XDG override is set.
234    #[test]
235    fn resolved_busy_policy_defaults_match_constants() {
236        assert_eq!(resolved_busy_retries(), MAX_SQLITE_BUSY_RETRIES);
237        assert_eq!(resolved_busy_base_delay_ms(), SQLITE_BUSY_BASE_DELAY_MS);
238    }
239}