Skip to main content

dbg_cli/session_db/
schema.rs

1//! SessionDb schema DDL.
2//!
3//! Every minor version can break schema. Old DBs that don't match
4//! `SCHEMA_VERSION` fail to load with a clear "re-collect" message.
5//! There is no migration path — the raw native files under
6//! `.dbg/sessions/<label>/raw/` are the durable artifact and `dbg profile-*`
7//! can always regenerate an index from them.
8
9use anyhow::Result;
10use rusqlite::Connection;
11
12use super::TargetClass;
13
14/// Bump this on every schema-breaking change. No migrations.
15pub const SCHEMA_VERSION: i64 = 1;
16
17/// Shared meta tables — always created regardless of track or target class.
18pub const CORE_DDL: &str = "
19CREATE TABLE IF NOT EXISTS sessions (
20    id              TEXT PRIMARY KEY,
21    kind            TEXT NOT NULL CHECK (kind IN ('debug','profile')),
22    target          TEXT NOT NULL,
23    target_class    TEXT NOT NULL,
24    target_hash     TEXT,
25    started_at      TEXT NOT NULL,
26    ended_at        TEXT,
27    label           TEXT NOT NULL,
28    created_by      TEXT NOT NULL DEFAULT 'auto'
29);
30
31CREATE TABLE IF NOT EXISTS layers (
32    id              INTEGER PRIMARY KEY,
33    session_id      TEXT NOT NULL REFERENCES sessions(id),
34    source          TEXT NOT NULL,
35    file            TEXT,
36    collected_at    TEXT,
37    command_used    TEXT,
38    collection_secs REAL,
39    target_hash     TEXT
40);
41CREATE INDEX IF NOT EXISTS idx_layers_session ON layers(session_id);
42
43CREATE TABLE IF NOT EXISTS symbols (
44    id              INTEGER PRIMARY KEY,
45    session_id      TEXT NOT NULL REFERENCES sessions(id),
46    lang            TEXT NOT NULL,
47    fqn             TEXT NOT NULL,
48    file            TEXT,
49    line            INTEGER,
50    demangled       TEXT,
51    raw             TEXT NOT NULL,
52    is_synthetic    INTEGER NOT NULL DEFAULT 0,
53    UNIQUE(session_id, lang, fqn)
54);
55CREATE INDEX IF NOT EXISTS idx_symbols_fqn ON symbols(lang, fqn);
56
57CREATE TABLE IF NOT EXISTS meta (
58    session_id      TEXT NOT NULL REFERENCES sessions(id),
59    key             TEXT NOT NULL,
60    value           TEXT,
61    PRIMARY KEY (session_id, key)
62);
63
64CREATE TABLE IF NOT EXISTS failures (
65    session_id      TEXT NOT NULL REFERENCES sessions(id),
66    phase           TEXT,
67    error           TEXT
68);
69
70CREATE TABLE IF NOT EXISTS regions (
71    id              INTEGER PRIMARY KEY,
72    session_id      TEXT NOT NULL REFERENCES sessions(id),
73    name            TEXT NOT NULL,
74    start_us        REAL,
75    duration_us     REAL,
76    thread          TEXT,
77    layer_id        INTEGER REFERENCES layers(id)
78);
79CREATE INDEX IF NOT EXISTS idx_regions_session ON regions(session_id);
80
81CREATE TABLE IF NOT EXISTS allocations (
82    id              INTEGER PRIMARY KEY,
83    session_id      TEXT NOT NULL REFERENCES sessions(id),
84    op              TEXT NOT NULL,
85    address         INTEGER NOT NULL,
86    bytes           INTEGER NOT NULL,
87    start_us        REAL,
88    heap            TEXT NOT NULL DEFAULT 'default',
89    thread          TEXT,
90    stack_json      TEXT,
91    layer_id        INTEGER REFERENCES layers(id)
92);
93CREATE INDEX IF NOT EXISTS idx_alloc_addr ON allocations(session_id, address);
94CREATE INDEX IF NOT EXISTS idx_alloc_time ON allocations(session_id, start_us);
95";
96
97/// Debug track tables — created for every session regardless of kind.
98/// A profile session simply never writes to them.
99pub const DEBUG_DDL: &str = "
100CREATE TABLE IF NOT EXISTS commands (
101    seq             INTEGER PRIMARY KEY,
102    session_id      TEXT NOT NULL REFERENCES sessions(id),
103    input           TEXT NOT NULL,
104    output_head     TEXT,
105    output_file     TEXT,
106    output_bytes    INTEGER,
107    ts              TEXT NOT NULL,
108    canonical_op    TEXT
109);
110CREATE INDEX IF NOT EXISTS idx_commands_session ON commands(session_id);
111
112CREATE TABLE IF NOT EXISTS breakpoint_hits (
113    id              INTEGER PRIMARY KEY,
114    session_id      TEXT NOT NULL REFERENCES sessions(id),
115    location_key    TEXT NOT NULL,
116    hit_seq         INTEGER NOT NULL,
117    thread          TEXT,
118    ts              TEXT NOT NULL,
119    locals_json     TEXT,
120    stack_json      TEXT
121);
122CREATE INDEX IF NOT EXISTS idx_bp_location ON breakpoint_hits(session_id, location_key);
123
124CREATE TABLE IF NOT EXISTS watch_evals (
125    id              INTEGER PRIMARY KEY,
126    session_id      TEXT NOT NULL REFERENCES sessions(id),
127    hit_id          INTEGER REFERENCES breakpoint_hits(id),
128    expr            TEXT NOT NULL,
129    value           TEXT,
130    type_name       TEXT,
131    ts              TEXT NOT NULL
132);
133";
134
135/// Cross-track shared tables — joinable from either debug or profile
136/// tracks via `symbol_id`. Populated by on-demand collectors.
137pub const CROSSTRACK_DDL: &str = "
138CREATE TABLE IF NOT EXISTS disassembly (
139    id              INTEGER PRIMARY KEY,
140    session_id      TEXT NOT NULL REFERENCES sessions(id),
141    symbol_id       INTEGER REFERENCES symbols(id),
142    source          TEXT NOT NULL,
143    tier            TEXT,
144    code_bytes      INTEGER,
145    asm_text        TEXT NOT NULL,
146    asm_lines_json  TEXT,
147    collected_at    TEXT NOT NULL,
148    trigger         TEXT,
149    layer_id        INTEGER REFERENCES layers(id)
150);
151CREATE INDEX IF NOT EXISTS idx_disasm_sym ON disassembly(symbol_id);
152
153CREATE TABLE IF NOT EXISTS source_snapshots (
154    id              INTEGER PRIMARY KEY,
155    session_id      TEXT NOT NULL REFERENCES sessions(id),
156    symbol_id       INTEGER REFERENCES symbols(id),
157    file            TEXT,
158    line_start      INTEGER,
159    line_end        INTEGER,
160    text            TEXT,
161    content_hash    TEXT,
162    collected_at    TEXT NOT NULL
163);
164CREATE INDEX IF NOT EXISTS idx_src_sym ON source_snapshots(symbol_id);
165
166CREATE TABLE IF NOT EXISTS alloc_sites (
167    id              INTEGER PRIMARY KEY,
168    session_id      TEXT NOT NULL REFERENCES sessions(id),
169    symbol_id       INTEGER REFERENCES symbols(id),
170    bytes_total     INTEGER,
171    count           INTEGER,
172    largest_bytes   INTEGER,
173    collected_at    TEXT NOT NULL,
174    layer_id        INTEGER REFERENCES layers(id)
175);
176CREATE INDEX IF NOT EXISTS idx_alloc_site_sym ON alloc_sites(symbol_id);
177";
178
179/// GPU domain tables (CUDA launches, ncu metrics, memcpy transfers,
180/// framework ops). From gdbg's current schema, with `session_id` added.
181pub const GPU_DDL: &str = "
182CREATE TABLE IF NOT EXISTS launches (
183    id              INTEGER PRIMARY KEY,
184    session_id      TEXT NOT NULL REFERENCES sessions(id),
185    kernel_name     TEXT NOT NULL,
186    duration_us     REAL NOT NULL,
187    grid_x          INTEGER, grid_y INTEGER, grid_z INTEGER,
188    block_x         INTEGER, block_y INTEGER, block_z INTEGER,
189    stream_id       INTEGER,
190    start_us        REAL,
191    correlation_id  INTEGER,
192    layer_id        INTEGER REFERENCES layers(id)
193);
194CREATE INDEX IF NOT EXISTS idx_launches_kernel ON launches(session_id, kernel_name);
195CREATE INDEX IF NOT EXISTS idx_launches_start  ON launches(session_id, start_us);
196CREATE INDEX IF NOT EXISTS idx_launches_stream ON launches(session_id, stream_id);
197
198CREATE TABLE IF NOT EXISTS metrics (
199    session_id                TEXT NOT NULL REFERENCES sessions(id),
200    kernel_name               TEXT NOT NULL,
201    occupancy_pct             REAL,
202    compute_throughput_pct    REAL,
203    memory_throughput_pct     REAL,
204    registers_per_thread      INTEGER,
205    shared_mem_static_bytes   INTEGER,
206    shared_mem_dynamic_bytes  INTEGER,
207    l2_hit_rate_pct           REAL,
208    achieved_bandwidth_gb_s   REAL,
209    peak_bandwidth_gb_s       REAL,
210    boundedness               TEXT,
211    layer_id                  INTEGER REFERENCES layers(id),
212    PRIMARY KEY (session_id, kernel_name)
213);
214
215CREATE TABLE IF NOT EXISTS transfers (
216    id              INTEGER PRIMARY KEY,
217    session_id      TEXT NOT NULL REFERENCES sessions(id),
218    kind            TEXT NOT NULL,
219    bytes           INTEGER,
220    duration_us     REAL,
221    start_us        REAL,
222    stream_id       INTEGER,
223    layer_id        INTEGER REFERENCES layers(id)
224);
225
226CREATE TABLE IF NOT EXISTS ops (
227    id              INTEGER PRIMARY KEY,
228    session_id      TEXT NOT NULL REFERENCES sessions(id),
229    name            TEXT NOT NULL,
230    module_path     TEXT,
231    cpu_time_us     REAL,
232    gpu_time_us     REAL,
233    input_shapes    TEXT,
234    layer_id        INTEGER REFERENCES layers(id)
235);
236
237CREATE TABLE IF NOT EXISTS op_kernel_map (
238    session_id      TEXT NOT NULL REFERENCES sessions(id),
239    op_id           INTEGER NOT NULL REFERENCES ops(id),
240    kernel_name     TEXT NOT NULL,
241    PRIMARY KEY (op_id, kernel_name)
242);
243";
244
245/// CPU-unified profile tables — sampling + EAV counters. Used by
246/// native-cpu, managed-dotnet, jvm, python, js/node target classes.
247pub const CPU_DDL: &str = "
248CREATE TABLE IF NOT EXISTS samples (
249    id              INTEGER PRIMARY KEY,
250    session_id      TEXT NOT NULL REFERENCES sessions(id),
251    symbol_id       INTEGER REFERENCES symbols(id),
252    thread          TEXT,
253    start_us        REAL,
254    duration_us     REAL,
255    cpu_ns          INTEGER,
256    weight          REAL NOT NULL DEFAULT 1.0,
257    stack_json      TEXT,
258    layer_id        INTEGER REFERENCES layers(id)
259);
260CREATE INDEX IF NOT EXISTS idx_samples_sym   ON samples(session_id, symbol_id);
261CREATE INDEX IF NOT EXISTS idx_samples_start ON samples(session_id, start_us);
262
263CREATE TABLE IF NOT EXISTS counters (
264    id              INTEGER PRIMARY KEY,
265    session_id      TEXT NOT NULL REFERENCES sessions(id),
266    name            TEXT NOT NULL,
267    symbol_id       INTEGER REFERENCES symbols(id),
268    value           REAL NOT NULL,
269    unit            TEXT,
270    layer_id        INTEGER REFERENCES layers(id)
271);
272CREATE INDEX IF NOT EXISTS idx_counters_name ON counters(session_id, name);
273";
274
275/// Managed-runtime tables (.NET, JVM).
276pub const MANAGED_DDL: &str = "
277CREATE TABLE IF NOT EXISTS gc_events (
278    id                INTEGER PRIMARY KEY,
279    session_id        TEXT NOT NULL REFERENCES sessions(id),
280    kind              TEXT NOT NULL,
281    pause_us          REAL,
282    start_us          REAL,
283    heap_before_bytes INTEGER,
284    heap_after_bytes  INTEGER,
285    reason            TEXT,
286    layer_id          INTEGER REFERENCES layers(id)
287);
288CREATE INDEX IF NOT EXISTS idx_gc_start ON gc_events(session_id, start_us);
289
290CREATE TABLE IF NOT EXISTS jit_events (
291    id              INTEGER PRIMARY KEY,
292    session_id      TEXT NOT NULL REFERENCES sessions(id),
293    symbol_id       INTEGER REFERENCES symbols(id),
294    compile_us      REAL,
295    code_bytes      INTEGER,
296    tier            TEXT,
297    start_us        REAL,
298    layer_id        INTEGER REFERENCES layers(id)
299);
300";
301
302/// Python-specific tables (GIL contention).
303pub const PYTHON_DDL: &str = "
304CREATE TABLE IF NOT EXISTS gil_events (
305    id              INTEGER PRIMARY KEY,
306    session_id      TEXT NOT NULL REFERENCES sessions(id),
307    kind            TEXT NOT NULL,
308    thread          TEXT,
309    start_us        REAL,
310    duration_us     REAL,
311    layer_id        INTEGER REFERENCES layers(id)
312);
313";
314
315/// Node/JS-specific tables (event-loop lag).
316pub const NODE_DDL: &str = "
317CREATE TABLE IF NOT EXISTS event_loop_lags (
318    id              INTEGER PRIMARY KEY,
319    session_id      TEXT NOT NULL REFERENCES sessions(id),
320    lag_us          REAL NOT NULL,
321    start_us        REAL,
322    phase           TEXT,
323    layer_id        INTEGER REFERENCES layers(id)
324);
325";
326
327/// Apply the shared meta + debug + cross-track DDL plus the per-class
328/// domain tables. Safe to call on a fresh or existing DB; uses
329/// `CREATE TABLE IF NOT EXISTS` everywhere. Schema-version enforcement
330/// happens in `SessionDb::open`, not here.
331pub fn apply(conn: &Connection, class: TargetClass) -> Result<()> {
332    conn.execute_batch(CORE_DDL)?;
333    conn.execute_batch(DEBUG_DDL)?;
334    conn.execute_batch(CROSSTRACK_DDL)?;
335    match class {
336        TargetClass::Gpu => conn.execute_batch(GPU_DDL)?,
337        TargetClass::NativeCpu => conn.execute_batch(CPU_DDL)?,
338        TargetClass::ManagedDotnet | TargetClass::Jvm => {
339            conn.execute_batch(CPU_DDL)?;
340            conn.execute_batch(MANAGED_DDL)?;
341        }
342        TargetClass::Python => {
343            conn.execute_batch(CPU_DDL)?;
344            conn.execute_batch(PYTHON_DDL)?;
345        }
346        TargetClass::JsNode => {
347            conn.execute_batch(CPU_DDL)?;
348            conn.execute_batch(NODE_DDL)?;
349        }
350        // Ruby and PHP share the CPU-unified profile shape for now.
351        // They get their own TargetClass so the `sessions` listing shows
352        // a meaningful runtime tag instead of `native-cpu`.
353        TargetClass::Ruby | TargetClass::Php => {
354            conn.execute_batch(CPU_DDL)?;
355        }
356    }
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn in_mem() -> Connection {
365        Connection::open_in_memory().unwrap()
366    }
367
368    #[test]
369    fn applies_cleanly_per_class() {
370        for class in [
371            TargetClass::Gpu,
372            TargetClass::NativeCpu,
373            TargetClass::ManagedDotnet,
374            TargetClass::Jvm,
375            TargetClass::Python,
376            TargetClass::JsNode,
377            TargetClass::Ruby,
378            TargetClass::Php,
379        ] {
380            let c = in_mem();
381            apply(&c, class).unwrap_or_else(|e| panic!("apply({class:?}): {e}"));
382        }
383    }
384
385    #[test]
386    fn apply_is_idempotent() {
387        let c = in_mem();
388        apply(&c, TargetClass::Gpu).unwrap();
389        apply(&c, TargetClass::Gpu).unwrap();
390        apply(&c, TargetClass::Gpu).unwrap();
391    }
392
393    #[test]
394    fn core_tables_exist_after_apply() {
395        let c = in_mem();
396        apply(&c, TargetClass::NativeCpu).unwrap();
397        for t in ["sessions", "layers", "symbols", "meta", "failures",
398                  "regions", "allocations",
399                  "commands", "breakpoint_hits", "watch_evals",
400                  "disassembly", "source_snapshots", "alloc_sites",
401                  "samples", "counters"] {
402            let exists: i64 = c.query_row(
403                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
404                [t],
405                |r| r.get(0),
406            ).unwrap();
407            assert_eq!(exists, 1, "missing table: {t}");
408        }
409    }
410
411    #[test]
412    fn gpu_class_has_launches_but_not_samples() {
413        let c = in_mem();
414        apply(&c, TargetClass::Gpu).unwrap();
415        let launches: i64 = c.query_row(
416            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='launches'",
417            [], |r| r.get(0)).unwrap();
418        assert_eq!(launches, 1);
419        let samples: i64 = c.query_row(
420            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='samples'",
421            [], |r| r.get(0)).unwrap();
422        assert_eq!(samples, 0, "samples table should not exist for GPU class");
423    }
424}