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`.
47///
48/// # Errors
49/// Returns [`AppError::DbBusy`] once the resolved budget is exhausted, and
50/// propagates any non-busy error from `op` unchanged.
51pub fn with_busy_retry<T, F>(op: F) -> Result<T, AppError>
52where
53 F: Fn() -> Result<T, AppError>,
54{
55 with_busy_retry_policy(resolved_busy_retries(), resolved_busy_base_delay_ms(), op)
56}
57
58/// [`with_busy_retry`] with the schedule supplied instead of resolved.
59///
60/// The ambient resolution is correct for the binary and wrong for a test. The
61/// schedule is exponential, so its total duration is governed by whatever the
62/// DEVELOPER happens to have in XDG: at the compiled defaults of 5 retries and
63/// 300 ms a contention test costs about nine seconds, while a machine carrying
64/// `db.busy_retries = 12` and `db.busy_base_delay_ms = 600` pays roughly THIRTY
65/// MINUTES for the same test. Measured, not theorised — it is why the suite
66/// appeared to hang for four consecutive sessions on this workstation, and the
67/// two tests involved were reported as "running for over 60 seconds" while they
68/// were in fact working exactly as written.
69///
70/// A test that reads ambient configuration is the same confused-deputy shape
71/// GAP-SG-205 describes: behaviour governed by state nobody declared. So the
72/// contention tests state their own schedule and the binary keeps resolving its.
73///
74/// # Errors
75/// Returns [`AppError::DbBusy`] once `max_retries` attempts are exhausted, and
76/// propagates any non-busy error unchanged.
77pub fn with_busy_retry_policy<T, F>(
78 max_retries: u32,
79 base_delay_ms: u64,
80 op: F,
81) -> Result<T, AppError>
82where
83 F: Fn() -> Result<T, AppError>,
84{
85 let max_retries = max_retries.max(1);
86 let base_delay_ms = base_delay_ms.max(1);
87 for attempt in 0..max_retries {
88 match op() {
89 Ok(v) => return Ok(v),
90 Err(e) if is_sqlite_busy(&e) => {
91 if crate::retry::is_kill_switch_active() {
92 tracing::warn!(target: "storage", "retry.disable is set, propagating SQLITE_BUSY immediately");
93 return Err(e);
94 }
95 // Saturating shift: attempt is bounded by max_retries (u32, small).
96 let shift = attempt.min(63);
97 // Capped BEFORE the jitter is drawn, so the half-jitter window
98 // stays inside the ceiling instead of straddling it.
99 let base_ms = base_delay_ms
100 .saturating_mul(1u64 << shift)
101 .min(crate::constants::SQLITE_BUSY_MAX_DELAY_MS);
102 let half = base_ms / 2;
103 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
104 let delay_ms = half + jitter;
105 tracing::debug!(
106 target: "storage",
107 attempt = attempt + 1,
108 attempt_max = max_retries,
109 delay_ms,
110 "SQLITE_BUSY retry with half-jitter"
111 );
112 thread::sleep(Duration::from_millis(delay_ms));
113 }
114 Err(other) => return Err(other),
115 }
116 }
117
118 tracing::error!(
119 target: "storage",
120 retries = max_retries,
121 "SQLITE_BUSY exhausted all retries"
122 );
123 Err(AppError::DbBusy(
124 crate::i18n::errors_ops::sqlite_busy_after_retries(max_retries),
125 ))
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::sync::atomic::{AtomicU32, Ordering};
132 use std::sync::Arc;
133
134 /// Helper that builds a fake `AppError::Database` wrapping
135 /// `SQLITE_BUSY` (error code 5) so that `is_sqlite_busy` can be tested
136 /// without needing a live SQLite connection.
137 fn make_busy_error() -> AppError {
138 // rusqlite::Error::SqliteFailure requires a `ffi::Error` + optional msg.
139 // We construct it via the public `rusqlite::ffi` interface.
140 let ffi_err = rusqlite::ffi::Error {
141 code: ErrorCode::DatabaseBusy,
142 extended_code: 5,
143 };
144 AppError::Database(rusqlite::Error::SqliteFailure(ffi_err, None))
145 }
146
147 fn make_locked_error() -> AppError {
148 let ffi_err = rusqlite::ffi::Error {
149 code: ErrorCode::DatabaseLocked,
150 extended_code: 6,
151 };
152 AppError::Database(rusqlite::Error::SqliteFailure(ffi_err, None))
153 }
154
155 #[test]
156 fn is_sqlite_busy_detects_database_busy() {
157 assert!(is_sqlite_busy(&make_busy_error()));
158 }
159
160 #[test]
161 fn is_sqlite_busy_detects_database_locked() {
162 assert!(is_sqlite_busy(&make_locked_error()));
163 }
164
165 #[test]
166 fn is_sqlite_busy_rejects_other_errors() {
167 let err = AppError::Validation("invalid field".into());
168 assert!(!is_sqlite_busy(&err));
169 }
170
171 #[test]
172 fn with_busy_retry_propagates_non_busy_error() {
173 let calls = Arc::new(AtomicU32::new(0));
174 let calls_clone = Arc::clone(&calls);
175
176 let result: Result<(), AppError> = with_busy_retry(|| {
177 calls_clone.fetch_add(1, Ordering::SeqCst);
178 Err(AppError::Validation("campo x".into()))
179 });
180
181 // Non-busy errors must propagate immediately without retrying.
182 assert_eq!(calls.load(Ordering::SeqCst), 1);
183 assert!(matches!(result, Err(AppError::Validation(_))));
184 }
185
186 #[test]
187 fn with_busy_retry_succeeds_on_third_attempt() {
188 let calls = Arc::new(AtomicU32::new(0));
189 let calls_clone = Arc::clone(&calls);
190
191 // Fail twice with SQLITE_BUSY, succeed on the third call.
192 let result = with_busy_retry(|| {
193 let n = calls_clone.fetch_add(1, Ordering::SeqCst);
194 if n < 2 {
195 Err(make_busy_error())
196 } else {
197 Ok(())
198 }
199 });
200
201 assert_eq!(calls.load(Ordering::SeqCst), 3);
202 assert!(result.is_ok(), "expected Ok after 3rd attempt");
203 }
204
205 #[test]
206 fn busy_retry_jitter_in_range() {
207 // Verify that the half-jitter formula stays within [base/2, base) for attempt=2.
208 // attempt=2 → base_ms = SQLITE_BUSY_BASE_DELAY_MS * 4; half = base_ms/2.
209 // We call fastrand::u64 indirectly through with_busy_retry by observing that the
210 // function completes; direct delay bounds are tested via the formula invariant.
211 let base_ms = SQLITE_BUSY_BASE_DELAY_MS * (1u64 << 2); // attempt=2
212 let half = base_ms / 2;
213 for _ in 0..100 {
214 let jitter = fastrand::u64(0..half);
215 let delay_ms = half + jitter;
216 assert!(
217 delay_ms >= half && delay_ms < base_ms,
218 "delay_ms {delay_ms} out of [{half}, {base_ms})"
219 );
220 }
221 }
222
223 #[test]
224 fn with_busy_retry_returns_db_busy_after_all_retries() {
225 // Declared, not resolved: this test exhausts the whole budget, so under
226 // an operator schedule of 12 attempts at 600 ms it would sleep for about
227 // half an hour to assert a bound it can prove in milliseconds.
228 const ATTEMPTS: u32 = 5;
229 const BASE_DELAY_MS: u64 = 1;
230
231 let calls = Arc::new(AtomicU32::new(0));
232 let calls_clone = Arc::clone(&calls);
233
234 let result: Result<(), AppError> = with_busy_retry_policy(ATTEMPTS, BASE_DELAY_MS, || {
235 calls_clone.fetch_add(1, Ordering::SeqCst);
236 Err(make_busy_error())
237 });
238
239 assert_eq!(
240 calls.load(Ordering::SeqCst),
241 ATTEMPTS,
242 "must attempt exactly the declared busy-retry budget"
243 );
244 assert!(
245 matches!(result, Err(AppError::DbBusy(_))),
246 "must convert to DbBusy after exhausting retries"
247 );
248 }
249
250 /// GAP-SG-76/v1.1.00 fix: `with_busy_retry` was generalised from
251 /// `Result<(), AppError>` to `Result<T, AppError>` so the enrich dequeue
252 /// loops (which claim a non-unit `DequeueOutcome`) can reuse the bounded
253 /// helper instead of an unbounded `loop { ... continue; }` on
254 /// `SQLITE_BUSY`. This proves the generic path (a) still returns the
255 /// caller-typed `Ok(v)` on success and (b) is bounded (never spins
256 /// forever) when the wrapped operation always reports `SQLITE_BUSY`.
257 #[test]
258 fn with_busy_retry_is_generic_over_return_type() {
259 // (a) success path threads a non-unit T through unchanged.
260 let ok: Result<i64, AppError> = with_busy_retry(|| Ok(42i64));
261 assert_eq!(ok.unwrap(), 42);
262
263 // (b) exhaustion path is bounded for a non-unit T: exactly the
264 // resolved retry budget attempts, then Err(DbBusy), never an
265 // infinite retry loop.
266 let calls = Arc::new(AtomicU32::new(0));
267 let calls_clone = Arc::clone(&calls);
268 let result: Result<i64, AppError> = with_busy_retry(|| {
269 calls_clone.fetch_add(1, Ordering::SeqCst);
270 Err(make_busy_error())
271 });
272 assert_eq!(calls.load(Ordering::SeqCst), resolved_busy_retries());
273 assert!(matches!(result, Err(AppError::DbBusy(_))));
274 }
275
276 /// GAP-SG-87 / GAP-SG-199: the busy policy is always usable, whatever the
277 /// host config says.
278 ///
279 /// This asserts only what does NOT depend on the host: both resolvers apply
280 /// `.max(1)`, so neither can ever hand back a budget of zero that would
281 /// spin-zero or divide by nothing.
282 ///
283 /// The stronger claim — that an ABSENT override resolves to the compiled
284 /// constant — moved to `tests/busy_policy_hermetic.rs`, because it is only
285 /// true under an isolated XDG root. Asserting it here compared a RESOLVED
286 /// value against a compiled one while the resolver reads the developer's
287 /// real config, so `config set db.busy_retries 12` — a documented, legitimate
288 /// key — turned the suite red without a line of code changing.
289 #[test]
290 fn resolved_busy_policy_defaults_match_constants() {
291 assert!(
292 resolved_busy_retries() >= 1,
293 "retry budget must never resolve to zero"
294 );
295 assert!(
296 resolved_busy_base_delay_ms() >= 1,
297 "base delay must never resolve to zero"
298 );
299 }
300}