Skip to main content

dbg_cli/session_db/
lifecycle.rs

1//! SessionDb lifecycle: create, open (with strict schema-version check),
2//! save-to, prune, session groups, raw-file paths.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, bail};
9use rusqlite::{Connection, OptionalExtension, params};
10
11use super::schema::{self, SCHEMA_VERSION};
12use super::{SessionKind, TargetClass};
13
14/// An open SessionDb — either in-memory (fresh) or backed by a file.
15pub struct SessionDb {
16    conn: Connection,
17    session_id: String,
18    label: String,
19    kind: SessionKind,
20    target_class: TargetClass,
21    target: String,
22    db_path: Option<PathBuf>,
23}
24
25impl std::fmt::Debug for SessionDb {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("SessionDb")
28            .field("session_id", &self.session_id)
29            .field("label", &self.label)
30            .field("kind", &self.kind)
31            .field("target_class", &self.target_class)
32            .field("target", &self.target)
33            .field("db_path", &self.db_path)
34            .finish()
35    }
36}
37
38/// Parameters for `SessionDb::create`.
39pub struct CreateOptions<'a> {
40    pub kind: SessionKind,
41    pub target: &'a str,
42    pub target_class: TargetClass,
43    pub cwd: &'a Path,
44    /// `None` → in-memory DB. Call `save_to` later to persist.
45    pub db_path: Option<&'a Path>,
46    /// `None` → auto-generate `"{basename}-{yyyymmdd-hhmmss}"`.
47    pub label: Option<String>,
48    /// `None` → auto-compute `size:mtime_ns` if `target` is a file.
49    pub target_hash: Option<String>,
50}
51
52/// Whether `prune` deletes only auto-created sessions or everything over age.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum PrunePolicy {
55    AutoOnly,
56    All,
57}
58
59impl SessionDb {
60    /// Create a fresh SessionDb. Applies DDL, stamps `PRAGMA user_version`,
61    /// inserts the session row, and records session-group metadata.
62    pub fn create(opts: CreateOptions<'_>) -> Result<Self> {
63        let conn = match opts.db_path {
64            Some(p) => {
65                if let Some(parent) = p.parent() {
66                    fs::create_dir_all(parent).ok();
67                }
68                Connection::open(p)?
69            }
70            None => Connection::open_in_memory()?,
71        };
72        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
73            .ok(); // WAL unsupported in-memory — non-fatal.
74        schema::apply(&conn, opts.target_class)?;
75        conn.execute(
76            &format!("PRAGMA user_version = {SCHEMA_VERSION}"),
77            [],
78        )?;
79
80        let session_id = random_id(&conn)?;
81        let label = match opts.label {
82            Some(l) => l,
83            None => auto_label(opts.target)?,
84        };
85        let target_hash = match opts.target_hash {
86            Some(h) => Some(h),
87            None => compute_target_hash(Path::new(opts.target)).ok(),
88        };
89
90        conn.execute(
91            "INSERT INTO sessions
92                (id, kind, target, target_class, target_hash,
93                 started_at, label, created_by)
94             VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), ?6, 'auto')",
95            params![
96                session_id,
97                opts.kind.as_str(),
98                opts.target,
99                opts.target_class.as_str(),
100                target_hash,
101                label,
102            ],
103        )?;
104
105        // Record session-group key under meta so peers can be discovered
106        // by scanning `.dbg/sessions/*.db` and matching on this value.
107        let cwd_canonical = opts
108            .cwd
109            .canonicalize()
110            .unwrap_or_else(|_| opts.cwd.to_path_buf());
111        let group = group_key(&cwd_canonical, target_hash.as_deref().unwrap_or(""));
112        conn.execute(
113            "INSERT INTO meta (session_id, key, value) VALUES (?1, 'session_group_key', ?2)",
114            params![session_id, group],
115        )?;
116        conn.execute(
117            "INSERT INTO meta (session_id, key, value) VALUES (?1, 'cwd', ?2)",
118            params![session_id, cwd_canonical.to_string_lossy().as_ref()],
119        )?;
120
121        Ok(SessionDb {
122            conn,
123            session_id,
124            label,
125            kind: opts.kind,
126            target_class: opts.target_class,
127            target: opts.target.to_string(),
128            db_path: opts.db_path.map(Path::to_path_buf),
129        })
130    }
131
132    /// Open an existing SessionDb from disk. Refuses to open DBs with a
133    /// mismatched `PRAGMA user_version`: the caller must re-collect.
134    pub fn open(path: &Path) -> Result<Self> {
135        if !path.exists() {
136            bail!("session DB not found: {}", path.display());
137        }
138        let conn = Connection::open(path)
139            .with_context(|| format!("opening session DB {}", path.display()))?;
140
141        let found: i64 = conn
142            .query_row("PRAGMA user_version", [], |r| r.get(0))
143            .with_context(|| format!("reading user_version from {}", path.display()))?;
144        if found != SCHEMA_VERSION {
145            bail!(
146                "session DB schema_version={found}, expected {SCHEMA_VERSION} at {path}.\n\
147                 No migration path. Re-collect against the raw files in {raw} \
148                 (or delete the DB and re-run the originating command).",
149                found = found,
150                SCHEMA_VERSION = SCHEMA_VERSION,
151                path = path.display(),
152                raw = path
153                    .parent()
154                    .map(|p| p.join(path.file_stem().unwrap_or_default()).join("raw"))
155                    .map(|p| p.display().to_string())
156                    .unwrap_or_else(|| "(no raw dir)".into()),
157            );
158        }
159
160        // A well-formed DB has exactly one session row (the owning session).
161        let (session_id, label, kind_s, class_s, target): (String, String, String, String, String) =
162            conn.query_row(
163                "SELECT id, label, kind, target_class, target FROM sessions LIMIT 1",
164                [],
165                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
166            )
167            .with_context(|| format!("reading session row from {}", path.display()))?;
168
169        let kind = match kind_s.as_str() {
170            "debug" => SessionKind::Debug,
171            "profile" => SessionKind::Profile,
172            other => bail!("unknown session kind in DB: {other}"),
173        };
174        let target_class: TargetClass = class_s
175            .parse()
176            .with_context(|| format!("parsing target_class {class_s}"))?;
177
178        Ok(SessionDb {
179            conn,
180            session_id,
181            label,
182            kind,
183            target_class,
184            target,
185            db_path: Some(path.to_path_buf()),
186        })
187    }
188
189    /// Copy the current in-memory (or on-disk) DB to `path` using the SQLite
190    /// online backup API. The source DB stays usable.
191    pub fn save_to(&self, path: &Path) -> Result<()> {
192        if let Some(parent) = path.parent() {
193            fs::create_dir_all(parent)?;
194        }
195        let mut dst = Connection::open(path)?;
196        let backup = rusqlite::backup::Backup::new(&self.conn, &mut dst)?;
197        backup.run_to_completion(128, Duration::from_millis(50), None)?;
198        // The backup object cannot borrow `dst` after run_to_completion
199        // returns, so set user_version on the freshly-populated file.
200        drop(backup);
201        dst.execute(&format!("PRAGMA user_version = {SCHEMA_VERSION}"), [])?;
202        Ok(())
203    }
204
205    /// True iff the session has accumulated data worth keeping — either a
206    /// breakpoint hit was captured (debug track) or a data layer landed
207    /// (profile track). Used by the daemon shutdown path to decide whether
208    /// to persist or discard an auto session.
209    pub fn has_captured_data(&self) -> Result<bool> {
210        let hits: i64 = self.conn.query_row(
211            "SELECT COUNT(*) FROM breakpoint_hits WHERE session_id = ?1",
212            params![self.session_id],
213            |r| r.get(0),
214        )?;
215        if hits > 0 {
216            return Ok(true);
217        }
218        let layers: i64 = self.conn.query_row(
219            "SELECT COUNT(*) FROM layers WHERE session_id = ?1",
220            params![self.session_id],
221            |r| r.get(0),
222        )?;
223        Ok(layers > 0)
224    }
225
226    /// Promote this session so `prune` will never delete it.
227    pub fn promote_to_user(&self) -> Result<()> {
228        self.conn.execute(
229            "UPDATE sessions SET created_by = 'user' WHERE id = ?1",
230            params![self.session_id],
231        )?;
232        Ok(())
233    }
234
235    pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
236        self.conn.execute(
237            "INSERT INTO meta (session_id, key, value) VALUES (?1, ?2, ?3)
238             ON CONFLICT(session_id, key) DO UPDATE SET value = excluded.value",
239            params![self.session_id, key, value],
240        )?;
241        Ok(())
242    }
243
244    pub fn meta(&self, key: &str) -> Result<Option<String>> {
245        Ok(self.conn
246            .query_row(
247                "SELECT value FROM meta WHERE session_id = ?1 AND key = ?2",
248                params![self.session_id, key],
249                |r| r.get(0),
250            )
251            .optional()?)
252    }
253
254    pub fn conn(&self) -> &Connection { &self.conn }
255    pub fn session_id(&self) -> &str { &self.session_id }
256    pub fn label(&self) -> &str { &self.label }
257    pub fn kind(&self) -> SessionKind { self.kind }
258    pub fn target_class(&self) -> TargetClass { self.target_class }
259    pub fn target(&self) -> &str { &self.target }
260    pub fn db_path(&self) -> Option<&Path> { self.db_path.as_deref() }
261}
262
263/// `<cwd>/.dbg/sessions/` — where saved session DBs live.
264pub fn sessions_dir(cwd: &Path) -> PathBuf {
265    cwd.join(".dbg").join("sessions")
266}
267
268/// `<cwd>/.dbg/sessions/<label>/raw/` — where raw captures (`perf.data`,
269/// `nsys-rep`, `.nettrace`, etc.) are preserved verbatim. Per principle 5
270/// (adaptation layer), these files are the durable artifact.
271pub fn raw_dir(cwd: &Path, label: &str) -> PathBuf {
272    sessions_dir(cwd).join(label).join("raw")
273}
274
275/// Deterministic session-group id derived from `(cwd, target_hash)`.
276/// Two sessions share a group iff they were launched against the same
277/// target from the same working directory.
278pub fn group_key(cwd: &Path, target_hash: &str) -> String {
279    format!("{}|{}", cwd.display(), target_hash)
280}
281
282/// Cheap target fingerprint: `"<size>:<mtime_ns>"`. Good enough to detect
283/// "target has been rebuilt since last session". Not a cryptographic hash.
284pub fn compute_target_hash(target: &Path) -> Result<String> {
285    let md = fs::metadata(target)
286        .with_context(|| format!("stat {}", target.display()))?;
287    let size = md.len();
288    let mtime = md
289        .modified()
290        .ok()
291        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
292        .map(|d| d.as_nanos())
293        .unwrap_or(0);
294    Ok(format!("{size}:{mtime}"))
295}
296
297/// `"{basename}-{yyyymmdd-hhmmss}"` in the local-ish (system time) zone,
298/// computed from `SystemTime::now()` without pulling in chrono.
299pub fn auto_label(target: &str) -> Result<String> {
300    let basename = Path::new(target)
301        .file_name()
302        .and_then(|s| s.to_str())
303        .unwrap_or("session")
304        .to_string();
305    let secs = SystemTime::now()
306        .duration_since(UNIX_EPOCH)
307        .map(|d| d.as_secs())
308        .unwrap_or(0);
309    Ok(format!("{basename}-{}", format_timestamp(secs)))
310}
311
312/// UTC `yyyymmdd-hhmmss`. Proleptic Gregorian, no leap seconds —
313/// sufficient for session labels.
314fn format_timestamp(secs: u64) -> String {
315    let (y, mo, d, h, mi, s) = decompose_utc(secs);
316    format!("{y:04}{mo:02}{d:02}-{h:02}{mi:02}{s:02}")
317}
318
319fn decompose_utc(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
320    let s = (secs % 60) as u32;
321    secs /= 60;
322    let mi = (secs % 60) as u32;
323    secs /= 60;
324    let h = (secs % 24) as u32;
325    secs /= 24;
326    let mut days = secs as i64;
327
328    // Convert days-since-1970-01-01 to (year, month, day) — algorithm from
329    // Howard Hinnant's date library, public-domain.
330    days += 719_468;
331    let era = if days >= 0 { days / 146_097 } else { (days - 146_096) / 146_097 };
332    let doe = (days - era * 146_097) as u32;
333    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
334    let y = (yoe as i64) + era * 400;
335    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
336    let mp = (5 * doy + 2) / 153;
337    let d = doy - (153 * mp + 2) / 5 + 1;
338    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
339    let y = if mo <= 2 { y + 1 } else { y };
340    (y as u32, mo, d, h, mi, s)
341}
342
343fn random_id(conn: &Connection) -> Result<String> {
344    // 16-byte randomblob → 32-hex-char id. SQLite's CSPRNG.
345    let id: String =
346        conn.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
347    Ok(id)
348}
349
350/// Walk `sessions_dir`, delete `.db` files for auto sessions whose mtime
351/// is older than `older_than`. Returns the list of deleted paths.
352/// User-promoted sessions are never touched under `PrunePolicy::AutoOnly`.
353pub fn prune(
354    sessions_dir: &Path,
355    older_than: Duration,
356    policy: PrunePolicy,
357) -> Result<Vec<PathBuf>> {
358    if !sessions_dir.exists() {
359        return Ok(vec![]);
360    }
361    let cutoff = SystemTime::now() - older_than;
362    let mut deleted = vec![];
363
364    for entry in fs::read_dir(sessions_dir)? {
365        let entry = entry?;
366        let path = entry.path();
367        if path.extension().and_then(|s| s.to_str()) != Some("db") {
368            continue;
369        }
370        let md = match entry.metadata() {
371            Ok(m) => m,
372            Err(_) => continue,
373        };
374        let mtime = md.modified().unwrap_or(SystemTime::now());
375        if mtime > cutoff {
376            continue;
377        }
378
379        // Inspect created_by without opening via SessionDb (which would
380        // fail on version mismatch — intentional, but the prune path
381        // should still be able to remove obsolete files).
382        let should_delete = match policy {
383            PrunePolicy::All => true,
384            PrunePolicy::AutoOnly => is_auto_session(&path).unwrap_or(false),
385        };
386        if !should_delete {
387            continue;
388        }
389
390        // Remove the DB plus any adjacent raw-capture directory named
391        // after the label (file stem).
392        fs::remove_file(&path).ok();
393        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
394            let raw = sessions_dir.join(stem);
395            if raw.is_dir() {
396                fs::remove_dir_all(&raw).ok();
397            }
398        }
399        deleted.push(path);
400    }
401
402    Ok(deleted)
403}
404
405fn is_auto_session(path: &Path) -> Result<bool> {
406    let conn = Connection::open(path)?;
407    let v: i64 = conn
408        .query_row("PRAGMA user_version", [], |r| r.get(0))
409        .unwrap_or(-1);
410    if v != SCHEMA_VERSION {
411        // Unknown/old format — treat as auto so prune can clean it up.
412        return Ok(true);
413    }
414    let created_by: String = conn
415        .query_row("SELECT created_by FROM sessions LIMIT 1", [], |r| r.get(0))
416        .unwrap_or_else(|_| "auto".to_string());
417    Ok(created_by == "auto")
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use tempfile::TempDir;
424
425    fn opts<'a>(cwd: &'a Path, target: &'a str) -> CreateOptions<'a> {
426        CreateOptions {
427            kind: SessionKind::Debug,
428            target,
429            target_class: TargetClass::NativeCpu,
430            cwd,
431            db_path: None,
432            label: Some(format!("t-{target}")),
433            target_hash: Some("test".into()),
434        }
435    }
436
437    #[test]
438    fn create_inserts_session_row() {
439        let tmp = TempDir::new().unwrap();
440        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
441        assert_eq!(db.kind(), SessionKind::Debug);
442        assert_eq!(db.target(), "/bin/ls");
443        let (label, class, created_by): (String, String, String) = db
444            .conn
445            .query_row(
446                "SELECT label, target_class, created_by FROM sessions",
447                [],
448                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
449            )
450            .unwrap();
451        assert_eq!(label, "t-/bin/ls");
452        assert_eq!(class, "native-cpu");
453        assert_eq!(created_by, "auto");
454    }
455
456    #[test]
457    fn save_and_open_roundtrip() {
458        let tmp = TempDir::new().unwrap();
459        let path = tmp.path().join("roundtrip.db");
460        let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
461        src.save_to(&path).unwrap();
462
463        let opened = SessionDb::open(&path).unwrap();
464        assert_eq!(opened.session_id(), src.session_id());
465        assert_eq!(opened.label(), src.label());
466        assert_eq!(opened.target_class(), TargetClass::NativeCpu);
467    }
468
469    #[test]
470    fn open_refuses_mismatched_version() {
471        let tmp = TempDir::new().unwrap();
472        let path = tmp.path().join("bad.db");
473        {
474            let conn = Connection::open(&path).unwrap();
475            // Pretend this DB was written by a far-future version.
476            conn.execute("PRAGMA user_version = 9999", []).unwrap();
477        }
478        let err = SessionDb::open(&path).unwrap_err().to_string();
479        assert!(
480            err.contains("schema_version=9999") && err.contains("No migration path"),
481            "error missing expected phrases: {err}"
482        );
483    }
484
485    #[test]
486    fn has_captured_data_false_on_fresh_session() {
487        let tmp = TempDir::new().unwrap();
488        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
489        assert!(!db.has_captured_data().unwrap());
490    }
491
492    #[test]
493    fn has_captured_data_true_after_breakpoint_hit() {
494        let tmp = TempDir::new().unwrap();
495        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
496        db.conn
497            .execute(
498                "INSERT INTO breakpoint_hits
499                    (session_id, location_key, hit_seq, ts)
500                 VALUES (?1, 'main.c:42', 1, datetime('now'))",
501                params![db.session_id()],
502            )
503            .unwrap();
504        assert!(db.has_captured_data().unwrap());
505    }
506
507    #[test]
508    fn has_captured_data_true_after_layer() {
509        let tmp = TempDir::new().unwrap();
510        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
511        db.conn
512            .execute(
513                "INSERT INTO layers (session_id, source) VALUES (?1, 'perf')",
514                params![db.session_id()],
515            )
516            .unwrap();
517        assert!(db.has_captured_data().unwrap());
518    }
519
520    #[test]
521    fn promote_to_user_sticks_through_save() {
522        let tmp = TempDir::new().unwrap();
523        let path = tmp.path().join("user.db");
524        let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
525        src.promote_to_user().unwrap();
526        src.save_to(&path).unwrap();
527        let opened = SessionDb::open(&path).unwrap();
528        let cb: String = opened
529            .conn
530            .query_row("SELECT created_by FROM sessions", [], |r| r.get(0))
531            .unwrap();
532        assert_eq!(cb, "user");
533    }
534
535    #[test]
536    fn set_and_get_meta() {
537        let tmp = TempDir::new().unwrap();
538        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
539        db.set_meta("custom", "hello").unwrap();
540        assert_eq!(db.meta("custom").unwrap(), Some("hello".into()));
541        db.set_meta("custom", "world").unwrap();
542        assert_eq!(db.meta("custom").unwrap(), Some("world".into()));
543    }
544
545    #[test]
546    fn session_group_key_persisted_at_create() {
547        let tmp = TempDir::new().unwrap();
548        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
549        let g = db.meta("session_group_key").unwrap().unwrap();
550        assert!(g.ends_with("|test"), "unexpected group key: {g}");
551    }
552
553    #[test]
554    fn prune_auto_only_keeps_user_sessions() {
555        let tmp = TempDir::new().unwrap();
556        let dir = tmp.path().to_path_buf();
557
558        let auto_path = dir.join("auto.db");
559        let user_path = dir.join("user.db");
560        let a = SessionDb::create(CreateOptions {
561            db_path: None,
562            ..opts(&dir, "/bin/ls")
563        })
564        .unwrap();
565        a.save_to(&auto_path).unwrap();
566
567        let u = SessionDb::create(CreateOptions {
568            db_path: None,
569            ..opts(&dir, "/bin/ls")
570        })
571        .unwrap();
572        u.promote_to_user().unwrap();
573        u.save_to(&user_path).unwrap();
574
575        // Give the fs a tick so our ZERO-age cutoff sits at-or-after the
576        // file mtimes (required on coarse-grained timestamp filesystems).
577        std::thread::sleep(Duration::from_millis(10));
578
579        let deleted = prune(&dir, Duration::ZERO, PrunePolicy::AutoOnly).unwrap();
580        assert_eq!(deleted.len(), 1, "deleted: {deleted:?}");
581        assert!(deleted[0].ends_with("auto.db"));
582        assert!(user_path.exists());
583        assert!(!auto_path.exists());
584    }
585
586    #[test]
587    fn prune_skips_files_newer_than_cutoff() {
588        let tmp = TempDir::new().unwrap();
589        let dir = tmp.path().to_path_buf();
590        let p = dir.join("fresh.db");
591        let s = SessionDb::create(CreateOptions {
592            db_path: None,
593            ..opts(&dir, "/bin/ls")
594        })
595        .unwrap();
596        s.save_to(&p).unwrap();
597        // One-day cutoff should keep a just-created file.
598        let deleted = prune(&dir, Duration::from_secs(86_400), PrunePolicy::AutoOnly).unwrap();
599        assert!(deleted.is_empty());
600        assert!(p.exists());
601    }
602
603    #[test]
604    fn auto_label_has_expected_shape() {
605        let lbl = auto_label("/usr/bin/ls").unwrap();
606        assert!(lbl.starts_with("ls-"), "label: {lbl}");
607        let suffix = lbl.trim_start_matches("ls-");
608        assert_eq!(suffix.len(), 15); // yyyymmdd-hhmmss
609        let (date, time) = suffix.split_once('-').unwrap();
610        assert_eq!(date.len(), 8);
611        assert_eq!(time.len(), 6);
612        assert!(date.chars().all(|c| c.is_ascii_digit()));
613        assert!(time.chars().all(|c| c.is_ascii_digit()));
614    }
615
616    #[test]
617    fn raw_dir_is_sibling_of_label() {
618        let d = raw_dir(Path::new("/tmp/proj"), "myapp-20260415-120000");
619        assert_eq!(
620            d,
621            PathBuf::from("/tmp/proj/.dbg/sessions/myapp-20260415-120000/raw")
622        );
623    }
624
625    #[test]
626    fn group_key_stable_across_create_calls() {
627        let tmp = TempDir::new().unwrap();
628        let a = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
629        let b = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
630        assert_eq!(
631            a.meta("session_group_key").unwrap(),
632            b.meta("session_group_key").unwrap()
633        );
634    }
635}