Skip to main content

aft/db/
lifecycle.rs

1//! Process-wide accounting for SQLite connections opened by AFT-owned seams.
2//!
3//! The counter deliberately follows connection lifetime rather than query traffic:
4//! leaked SQLite handles keep file descriptors, WAL state, and page caches alive even
5//! while idle. Callers must use [`TrackedConnection`] at one of the documented seams.
6
7use std::collections::BTreeMap;
8use std::ops::{Deref, DerefMut};
9use std::path::Path;
10use std::sync::{Mutex, OnceLock};
11
12use rusqlite::{Connection, OpenFlags};
13use serde::Serialize;
14
15/// Names each production connection-opening seam so health can attribute a live
16/// connection without exposing cache-key paths in the process-wide report.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
18pub enum SqliteStore {
19    AftDb,
20    BlobStore,
21    CallgraphGeneration,
22    InspectScopeCache,
23    BreakerFile,
24}
25
26impl SqliteStore {
27    pub const ALL: [Self; 5] = [
28        Self::AftDb,
29        Self::BlobStore,
30        Self::CallgraphGeneration,
31        Self::InspectScopeCache,
32        Self::BreakerFile,
33    ];
34
35    pub const fn label(self) -> &'static str {
36        match self {
37            Self::AftDb => "aft.db",
38            Self::BlobStore => "blob_stores",
39            Self::CallgraphGeneration => "callgraph_generations",
40            Self::InspectScopeCache => "inspect_scope_caches",
41            Self::BreakerFile => "breaker_files",
42        }
43    }
44}
45
46/// A store/count row is used instead of a map so status consumers can retain a
47/// stable schema even when every tracked count is zero.
48#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
49pub struct SqliteStoreCount {
50    pub store: String,
51    pub count: u64,
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
55pub struct SqliteConnectionSnapshot {
56    pub open_connections: u64,
57    pub open_by_store: Vec<SqliteStoreCount>,
58    /// Production openers are all routed through the tracked seams below. Keep
59    /// this documented list explicit so a future exception cannot silently make
60    /// the process-wide count incomplete.
61    pub uninstrumented_openers: Vec<String>,
62}
63
64/// Production `rusqlite::Connection::open*` call sites that intentionally do
65/// not pass through [`TrackedConnection`]. The list is empty today. Test-only
66/// fixture openers are excluded because they cannot affect daemon lifecycle
67/// health and are compiled out of release builds.
68pub const SQLITE_UNINSTRUMENTED_OPENERS: &[&str] = &[];
69
70fn live_counts() -> &'static Mutex<BTreeMap<SqliteStore, u64>> {
71    static COUNTS: OnceLock<Mutex<BTreeMap<SqliteStore, u64>>> = OnceLock::new();
72    COUNTS.get_or_init(|| Mutex::new(BTreeMap::new()))
73}
74
75fn register_open(store: SqliteStore) {
76    let mut counts = live_counts()
77        .lock()
78        .unwrap_or_else(std::sync::PoisonError::into_inner);
79    *counts.entry(store).or_default() += 1;
80    #[cfg(test)]
81    thread_counts::record(store, 1);
82}
83
84fn register_close(store: SqliteStore) {
85    let mut counts = live_counts()
86        .lock()
87        .unwrap_or_else(std::sync::PoisonError::into_inner);
88    let count = counts.entry(store).or_default();
89    *count = count.saturating_sub(1);
90    #[cfg(test)]
91    thread_counts::record(store, -1);
92}
93
94/// Per-thread mirror of the open/close seam. The process-wide counter above is
95/// shared with every other test in the binary, which open and close their own
96/// connections concurrently, so a test cannot assert a delta against it. This
97/// mirror only ever sees the calling thread's own opens and closes.
98#[cfg(test)]
99pub(crate) mod thread_counts {
100    use super::SqliteStore;
101    use std::cell::RefCell;
102    use std::collections::BTreeMap;
103
104    thread_local! {
105        static COUNTS: RefCell<BTreeMap<SqliteStore, i64>> = RefCell::new(BTreeMap::new());
106        static OPENS: RefCell<BTreeMap<SqliteStore, u64>> = RefCell::new(BTreeMap::new());
107    }
108
109    pub(super) fn record(store: SqliteStore, delta: i64) {
110        COUNTS.with(|counts| *counts.borrow_mut().entry(store).or_default() += delta);
111        if delta > 0 {
112            OPENS.with(|counts| *counts.borrow_mut().entry(store).or_default() += 1);
113        }
114    }
115
116    pub(crate) fn total_opens_on_this_thread(store: SqliteStore) -> u64 {
117        OPENS.with(|counts| counts.borrow().get(&store).copied().unwrap_or(0))
118    }
119
120    pub(crate) fn open_on_this_thread(store: SqliteStore) -> i64 {
121        COUNTS.with(|counts| counts.borrow().get(&store).copied().unwrap_or(0))
122    }
123}
124
125/// Snapshot the current number of open connections. This takes a short counter
126/// lock only; OS/process enumeration remains outside this module.
127pub fn connection_snapshot() -> SqliteConnectionSnapshot {
128    let counts = live_counts()
129        .lock()
130        .unwrap_or_else(std::sync::PoisonError::into_inner);
131    let open_by_store = SqliteStore::ALL
132        .into_iter()
133        .map(|store| SqliteStoreCount {
134            store: store.label().to_string(),
135            count: counts.get(&store).copied().unwrap_or(0),
136        })
137        .collect::<Vec<_>>();
138    let open_connections = open_by_store.iter().map(|row| row.count).sum();
139    SqliteConnectionSnapshot {
140        open_connections,
141        open_by_store,
142        uninstrumented_openers: SQLITE_UNINSTRUMENTED_OPENERS
143            .iter()
144            .map(|opener| (*opener).to_string())
145            .collect(),
146    }
147}
148
149/// A `rusqlite::Connection` whose lifetime contributes to the process-wide
150/// health census. It dereferences to `Connection`, keeping existing query APIs
151/// and transaction helpers unchanged while making close accounting automatic.
152#[derive(Debug)]
153pub struct TrackedConnection {
154    connection: Option<Connection>,
155    store: SqliteStore,
156}
157
158impl TrackedConnection {
159    pub fn open(path: &Path, store: SqliteStore) -> rusqlite::Result<Self> {
160        Self::from_connection(Connection::open(path)?, store)
161    }
162
163    pub fn open_with_flags(
164        path: &str,
165        flags: OpenFlags,
166        store: SqliteStore,
167    ) -> rusqlite::Result<Self> {
168        Self::from_connection(Connection::open_with_flags(path, flags)?, store)
169    }
170
171    pub fn open_path_with_flags(
172        path: &Path,
173        flags: OpenFlags,
174        store: SqliteStore,
175    ) -> rusqlite::Result<Self> {
176        Self::from_connection(Connection::open_with_flags(path, flags)?, store)
177    }
178
179    pub fn open_in_memory(store: SqliteStore) -> rusqlite::Result<Self> {
180        Self::from_connection(Connection::open_in_memory()?, store)
181    }
182
183    pub fn from_connection(connection: Connection, store: SqliteStore) -> rusqlite::Result<Self> {
184        register_open(store);
185        Ok(Self {
186            connection: Some(connection),
187            store,
188        })
189    }
190}
191
192impl Deref for TrackedConnection {
193    type Target = Connection;
194
195    fn deref(&self) -> &Self::Target {
196        self.connection
197            .as_ref()
198            .expect("tracked SQLite connection accessed after drop")
199    }
200}
201
202impl DerefMut for TrackedConnection {
203    fn deref_mut(&mut self) -> &mut Self::Target {
204        self.connection
205            .as_mut()
206            .expect("tracked SQLite connection accessed after drop")
207    }
208}
209
210impl Drop for TrackedConnection {
211    fn drop(&mut self) {
212        // Drop the SQLite handle before decrementing so the counter never says
213        // closed while rusqlite still owns the descriptor and page cache.
214        drop(self.connection.take());
215        register_close(self.store);
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn store_count(snapshot: &SqliteConnectionSnapshot, store: SqliteStore) -> u64 {
224        snapshot
225            .open_by_store
226            .iter()
227            .find(|row| row.store == store.label())
228            .expect("every store has a snapshot row")
229            .count
230    }
231
232    #[test]
233    fn tracked_connection_counts_open_and_close_at_each_seam() {
234        use super::thread_counts::open_on_this_thread;
235        let baseline_aft = open_on_this_thread(SqliteStore::AftDb);
236        let baseline_callgraph = open_on_this_thread(SqliteStore::CallgraphGeneration);
237        let dir = tempfile::tempdir().expect("tempdir");
238        let aft = TrackedConnection::open(&dir.path().join("aft.db"), SqliteStore::AftDb)
239            .expect("open aft db");
240        let callgraph = TrackedConnection::open(
241            &dir.path().join("graph.sqlite"),
242            SqliteStore::CallgraphGeneration,
243        )
244        .expect("open callgraph");
245        assert_eq!(open_on_this_thread(SqliteStore::AftDb), baseline_aft + 1);
246        assert_eq!(
247            open_on_this_thread(SqliteStore::CallgraphGeneration),
248            baseline_callgraph + 1
249        );
250        // The process-wide snapshot must at least contain this thread's opens;
251        // it may also contain other tests' connections, so only a lower bound
252        // is a stable assertion here.
253        let snapshot = connection_snapshot();
254        assert!(store_count(&snapshot, SqliteStore::AftDb) >= 1);
255        assert!(store_count(&snapshot, SqliteStore::CallgraphGeneration) >= 1);
256        assert!(snapshot.open_connections >= 2);
257
258        drop(aft);
259        assert_eq!(open_on_this_thread(SqliteStore::AftDb), baseline_aft);
260        assert_eq!(
261            open_on_this_thread(SqliteStore::CallgraphGeneration),
262            baseline_callgraph + 1
263        );
264
265        drop(callgraph);
266        assert_eq!(open_on_this_thread(SqliteStore::AftDb), baseline_aft);
267        assert_eq!(
268            open_on_this_thread(SqliteStore::CallgraphGeneration),
269            baseline_callgraph
270        );
271    }
272
273    #[test]
274    fn every_uninstrumented_production_opener_is_documented() {
275        // This assertion intentionally names the documentation seam. If a
276        // production bypass is ever necessary, add its stable module/function
277        // name to the constant before accepting an incomplete health count.
278        assert!(SQLITE_UNINSTRUMENTED_OPENERS.is_empty());
279    }
280}