diff --git a/.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json b/.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json deleted file mode 100644 index 06cf717..0000000 --- a/.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT event_type as \"event_type!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\"\n FROM events GROUP BY event_type ORDER BY total_ms DESC", - "describe": { - "columns": [ - { - "name": "event_type!", - "ordinal": 0, - "type_info": "Int64" - }, - { - "name": "count!", - "ordinal": 1, - "type_info": "Int" - }, - { - "name": "total_ms", - "ordinal": 2, - "type_info": "Int" - }, - { - "name": "avg_ms!", - "ordinal": 3, - "type_info": "Float" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - true, - false, - true, - true - ] - }, - "hash": "089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53" -} diff --git a/.sqlx/query-0bcfc2ee3a0b7cf099fe676813616b903265959353dad8b3d263be382e335967.json b/.sqlx/query-0bcfc2ee3a0b7cf099fe676813616b903265959353dad8b3d263be382e335967.json new file mode 100644 index 0000000..05035cc --- /dev/null +++ b/.sqlx/query-0bcfc2ee3a0b7cf099fe676813616b903265959353dad8b3d263be382e335967.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT cache_url as \"cache_url!\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\", CAST(COUNT(*) AS INTEGER) as \"count!\"\n FROM events WHERE cache_url IS NOT NULL\n GROUP BY cache_url ORDER BY 2 DESC", + "describe": { + "columns": [ + { + "name": "cache_url!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "avg_ms!", + "ordinal": 1, + "type_info": "Float" + }, + { + "name": "count!", + "ordinal": 2, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + false + ] + }, + "hash": "0bcfc2ee3a0b7cf099fe676813616b903265959353dad8b3d263be382e335967" +} diff --git a/.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json b/.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json deleted file mode 100644 index 9745f26..0000000 --- a/.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT phase_name as \"name!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\"\n FROM phases GROUP BY phase_name ORDER BY total_ms DESC LIMIT 8", - "describe": { - "columns": [ - { - "name": "name!", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "count!", - "ordinal": 1, - "type_info": "Int" - }, - { - "name": "total_ms", - "ordinal": 2, - "type_info": "Int" - }, - { - "name": "avg_ms!", - "ordinal": 3, - "type_info": "Float" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - true, - false, - true, - true - ] - }, - "hash": "1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66" -} diff --git a/.sqlx/query-1bb0081dc5f5f17fdb0d8b1d5b957d5f0c80b2ed422c21640b71ebf9a1d97d1d.json b/.sqlx/query-1bb0081dc5f5f17fdb0d8b1d5b957d5f0c80b2ed422c21640b71ebf9a1d97d1d.json deleted file mode 100644 index 86f3d01..0000000 --- a/.sqlx/query-1bb0081dc5f5f17fdb0d8b1d5b957d5f0c80b2ed422c21640b71ebf9a1d97d1d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO phases (event_nix_id, phase_name, duration_ms) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "1bb0081dc5f5f17fdb0d8b1d5b957d5f0c80b2ed422c21640b71ebf9a1d97d1d" -} diff --git a/.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json b/.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json deleted file mode 100644 index 3389369..0000000 --- a/.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"cache_hits!\",\n CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"cache_misses!\",\n CAST(COALESCE(SUM(total_bytes), 0) AS INTEGER) as \"total_bytes!\",\n CAST(COALESCE(SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)), 0) AS INTEGER) as \"net_ms!\"\n FROM events", - "describe": { - "columns": [ - { - "name": "cache_hits!", - "ordinal": 0, - "type_info": "Int" - }, - { - "name": "cache_misses!", - "ordinal": 1, - "type_info": "Int" - }, - { - "name": "total_bytes!", - "ordinal": 2, - "type_info": "Int" - }, - { - "name": "net_ms!", - "ordinal": 3, - "type_info": "Int" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50" -} diff --git a/.sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json b/.sqlx/query-8018f90535a4f5b3a4997a6fadab4a8937bf63b32036740aac380656966d5626.json similarity index 53% rename from .sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json rename to .sqlx/query-8018f90535a4f5b3a4997a6fadab4a8937bf63b32036740aac380656966d5626.json index 3612d2e..8109ef4 100644 --- a/.sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json +++ b/.sqlx/query-8018f90535a4f5b3a4997a6fadab4a8937bf63b32036740aac380656966d5626.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT duration_ms as \"duration_ms!\", event_type as \"event_type!\", drv_path, text\n FROM events\n WHERE event_type IN (105, 108, 102, 112)\n ORDER BY duration_ms DESC LIMIT 10", + "query": "SELECT duration_ms as \"duration_ms!\", drv_path, text\n FROM events WHERE event_type = 105\n ORDER BY duration_ms DESC LIMIT 10", "describe": { "columns": [ { @@ -8,19 +8,14 @@ "ordinal": 0, "type_info": "Int64" }, - { - "name": "event_type!", - "ordinal": 1, - "type_info": "Int64" - }, { "name": "drv_path", - "ordinal": 2, + "ordinal": 1, "type_info": "Text" }, { "name": "text", - "ordinal": 3, + "ordinal": 2, "type_info": "Text" } ], @@ -28,11 +23,10 @@ "Right": 0 }, "nullable": [ - true, true, true, true ] }, - "hash": "794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f" + "hash": "8018f90535a4f5b3a4997a6fadab4a8937bf63b32036740aac380656966d5626" } diff --git a/.sqlx/query-9b40fd391fbe4613357eb865139a8e1e98273fd765c58a0d9f31b6ad38e59e26.json b/.sqlx/query-8230b2b702eee1a62a4970000c0f574cc4cca84858bda07ca760ed814b0a3756.json similarity index 63% rename from .sqlx/query-9b40fd391fbe4613357eb865139a8e1e98273fd765c58a0d9f31b6ad38e59e26.json rename to .sqlx/query-8230b2b702eee1a62a4970000c0f574cc4cca84858bda07ca760ed814b0a3756.json index da57446..8bbab90 100644 --- a/.sqlx/query-9b40fd391fbe4613357eb865139a8e1e98273fd765c58a0d9f31b6ad38e59e26.json +++ b/.sqlx/query-8230b2b702eee1a62a4970000c0f574cc4cca84858bda07ca760ed814b0a3756.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO events (nix_id, parent_id, event_type, text, drv_path, cache_url, start_time, end_time, duration_ms, total_bytes) \n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "query": "INSERT INTO events (nix_id, parent_id, event_type, text, drv_path, cache_url, start_time, end_time, duration_ms, total_bytes)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "9b40fd391fbe4613357eb865139a8e1e98273fd765c58a0d9f31b6ad38e59e26" + "hash": "8230b2b702eee1a62a4970000c0f574cc4cca84858bda07ca760ed814b0a3756" } diff --git a/.sqlx/query-c455929cf7c0ef853cba8765ef901dff54d94770c1e36404f096deb72882dfcb.json b/.sqlx/query-c455929cf7c0ef853cba8765ef901dff54d94770c1e36404f096deb72882dfcb.json new file mode 100644 index 0000000..1ef2377 --- /dev/null +++ b/.sqlx/query-c455929cf7c0ef853cba8765ef901dff54d94770c1e36404f096deb72882dfcb.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"build_count!\",\n COALESCE(SUM(duration_ms) FILTER (WHERE event_type = 105), 0) as \"build_total_ms!\",\n CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"subst_count!\",\n COALESCE(SUM(duration_ms) FILTER (WHERE event_type = 108), 0) as \"subst_total_ms!\",\n COALESCE(SUM(total_bytes) FILTER (WHERE event_type = 101), 0) as \"download_bytes!\",\n COALESCE(SUM(duration_ms) FILTER (WHERE event_type = 101), 0) as \"download_ms!\"\n FROM events", + "describe": { + "columns": [ + { + "name": "build_count!", + "ordinal": 0, + "type_info": "Int" + }, + { + "name": "build_total_ms!", + "ordinal": 1, + "type_info": "Int" + }, + { + "name": "subst_count!", + "ordinal": 2, + "type_info": "Int" + }, + { + "name": "subst_total_ms!", + "ordinal": 3, + "type_info": "Int" + }, + { + "name": "download_bytes!", + "ordinal": 4, + "type_info": "Int" + }, + { + "name": "download_ms!", + "ordinal": 5, + "type_info": "Int" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c455929cf7c0ef853cba8765ef901dff54d94770c1e36404f096deb72882dfcb" +} diff --git a/flake.lock b/flake.lock index 49b2092..aa21f06 100644 --- a/flake.lock +++ b/flake.lock @@ -16,22 +16,6 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1744536153, - "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { "nixpkgs": "nixpkgs", @@ -40,7 +24,9 @@ }, "rust-overlay": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1773284828, diff --git a/src/main.rs b/src/main.rs index 8e68c71..a39e93e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,20 +14,21 @@ use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Mutex; use tracing::{error, info}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "u64")] #[repr(u64)] enum ActivityType { Unknown = 0, CopyPath = 100, - FileTransfer = 101, + FileTransfer = 101, // Actual HTTP download Realise = 102, CopyPaths = 103, Builds = 104, - Build = 105, + Build = 105, // Individual derivation build OptimiseStore = 106, VerifyPaths = 107, - Substitute = 108, - QueryPathInfo = 109, + Substitute = 108, // High-level cache substitution + QueryPathInfo = 109, // Checking if path exists on cache PostBuildHook = 110, BuildWaiting = 111, FetchTree = 112, @@ -60,13 +61,17 @@ impl From for ActivityType { } } +impl Default for ActivityType { + fn default() -> Self { Self::Unknown } +} + impl fmt::Display for ActivityType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self) } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(u64)] enum ResultType { Unknown = 0, @@ -98,6 +103,16 @@ impl From for ResultType { } } +impl fmt::Display for ResultType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl Default for ResultType { + fn default() -> Self { Self::Unknown } +} + #[derive(Parser)] struct Cli { #[command(subcommand)] @@ -121,14 +136,20 @@ enum Commands { #[arg(short, long, env = "NOD_SOCKET")] socket: Option, }, + /// Clear all data from the database + Clean { + /// Path to the Unix socket + #[arg(short, long, env = "NOD_SOCKET")] + socket: Option, + }, } #[derive(Debug, Deserialize, Serialize)] struct NixEvent { action: String, id: u64, - #[serde(default)] - #[serde(rename = "type")] + // Raw number: ActivityType for "start", ResultType for "result", absent on "stop" + #[serde(default, rename = "type")] event_type: u64, #[serde(default)] text: String, @@ -138,39 +159,31 @@ struct NixEvent { parent: u64, } +// Sent over the socket as JSON - all computed values, no raw types #[derive(Debug, Serialize, Deserialize)] struct Stats { - by_type: Vec, - cache_hits: i32, - cache_misses: i32, - total_bytes: i32, - net_ms: i32, - by_phase: Vec, - slowest: Vec, + build_count: i64, + build_total_ms: i64, + subst_count: i64, + subst_total_ms: i64, + download_bytes: i64, + download_ms: i64, + slowest_builds: Vec, + cache_latency: Vec, } #[derive(Debug, Serialize, Deserialize)] -struct TypeStat { - event_type: i64, // table column INTEGER -> i64 - count: i32, // CAST(COUNT()) -> i32 - total_ms: Option, // CAST(SUM()) -> Option - avg_ms: f64, +struct SlowBuild { + duration_ms: i64, + drv_path: Option, + text: Option, } #[derive(Debug, Serialize, Deserialize)] -struct PhaseStat { - name: String, - count: i32, // CAST(COUNT()) -> i32 - total_ms: Option, // CAST(SUM()) -> Option +struct CacheStat { + cache_url: String, avg_ms: f64, -} - -#[derive(Debug, Serialize, Deserialize)] -struct SlowEvent { - duration_ms: i64, // table column INTEGER -> i64 - event_type: i64, // table column INTEGER -> i64 - drv_path: Option, - text: Option, + count: i64, } struct Activity { @@ -181,7 +194,6 @@ struct Activity { start_time: DateTime, fields: Vec, total_bytes: u64, - current_phase: Option<(String, DateTime)>, } struct State { @@ -267,6 +279,30 @@ async fn main() -> Result<()> { let stats: Stats = serde_json::from_str(&line).context("Invalid response from daemon")?; display_stats(stats); } + Commands::Clean { socket } => { + let socket_path = socket.unwrap_or_else(|| { + project_dirs.as_ref() + .and_then(|d| d.runtime_dir()) + .map(|d| d.join("nod.sock")) + .unwrap_or_else(|| PathBuf::from("/tmp/nod.sock")) + }); + + let mut stream = UnixStream::connect(&socket_path) + .await + .with_context(|| format!("Failed to connect to daemon at {}", socket_path.display()))?; + + stream.write_all(b"clean\n").await?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.context("Daemon closed connection")?; + + if line.trim() == "ok" { + info!("Database cleared successfully."); + } else { + error!("Daemon failed to clear database."); + } + } } Ok(()) @@ -304,12 +340,24 @@ async fn handle_connection(mut stream: UnixStream, state: Arc>) -> loop { line.clear(); if reader.read_line(&mut line).await? == 0 { break; } - if line.trim() == "get_stats" { + let cmd = line.trim(); + + if cmd == "get_stats" { let pool = state.lock().await.pool.clone(); let stats = collect_stats(pool).await?; writer.write_all((serde_json::to_string(&stats)? + "\n").as_bytes()).await?; break; } + if cmd == "clean" { + let pool = state.lock().await.pool.clone(); + sqlx::query("DELETE FROM phases").execute(&pool).await?; + sqlx::query("DELETE FROM events").execute(&pool).await?; + sqlx::query("VACUUM").execute(&pool).await?; + writer.write_all(b"ok\n").await?; + info!("Database cleared via socket command"); + break; + } + if let Ok(event) = serde_json::from_str::(&line) { process_event(event, &state).await?; } @@ -323,14 +371,21 @@ async fn process_event(event: NixEvent, state: &Arc>) -> Result<()> match event.action.as_str() { "start" => { let act_type = ActivityType::from(event.event_type); - let mut text = event.text; - - // Nix typically puts the relevant path in fields[0] for builds/substitutions - if let Some(path) = event.fields.get(0).and_then(|v| v.as_str()) { - text = path.to_string(); - } + let text = if !event.text.is_empty() { + event.text.clone() + } else { + event.fields.get(0).and_then(|v| v.as_str()).unwrap_or("").to_string() + }; + + info!( + id = event.id, + parent = event.parent, + act_type = %act_type, + text = %text, + fields = ?event.fields, + "start" + ); - info!(id = event.id, %act_type, "Start: {}", text); s.active_activities.insert(event.id, Activity { id: event.id, parent_id: event.parent, @@ -339,37 +394,23 @@ async fn process_event(event: NixEvent, state: &Arc>) -> Result<()> start_time: Utc::now(), fields: event.fields, total_bytes: 0, - current_phase: None, }); } "result" => { let res_type = ResultType::from(event.event_type); - let pool = s.pool.clone(); + + info!( + id = event.id, + res_type = %res_type, + fields = ?event.fields, + "result" + ); if let Some(act) = s.active_activities.get_mut(&event.id) { match res_type { - ResultType::SetPhase => { - let phase_name = event.fields.get(0).and_then(|v| v.as_str()).unwrap_or("unknown"); - let now = Utc::now(); - if let Some((name, start)) = act.current_phase.take() { - let duration = now.signed_duration_since(start).num_milliseconds(); - let nid = act.id as i64; - tokio::spawn(async move { - let _ = sqlx::query!( - "INSERT INTO phases (event_nix_id, phase_name, duration_ms) VALUES (?, ?, ?)", - nid, - name, - duration - ) - .execute(&pool) - .await; - }); - } - act.current_phase = Some((phase_name.to_string(), now)); - } ResultType::Progress => { if let Some(total) = event.fields.get(1).and_then(|v| v.as_u64()) { - act.total_bytes = total; + if total > 0 { act.total_bytes = total; } } } _ => {} @@ -378,121 +419,137 @@ async fn process_event(event: NixEvent, state: &Arc>) -> Result<()> } "stop" => { if let Some(act) = s.active_activities.remove(&event.id) { + let act_type = ActivityType::from(act.event_type); let end_time = Utc::now(); let duration = end_time.signed_duration_since(act.start_time).num_milliseconds(); - let act_type = ActivityType::from(act.event_type); - let drv_path = act.fields.get(0).and_then(|v| v.as_str()).map(|s| s.to_string()); - let mut cache_url = None; - if act_type == ActivityType::Substitute { - cache_url = act.fields.get(1).and_then(|v| v.as_str()).map(|s| s.to_string()); - } + info!( + id = act.id, + act_type = %act_type, + duration_ms = duration, + total_bytes = act.total_bytes, + text = %act.text, + fields = ?act.fields, + "stop" + ); - let nid = act.id as i64; - let pid = act.parent_id as i64; - let ety = act.event_type as i64; - let start_time = act.start_time.to_rfc3339(); + let drv_path = act.fields.get(0).and_then(|v| v.as_str()).map(|s| s.to_string()); + let cache_url = if act_type == ActivityType::QueryPathInfo { + act.fields.get(1).and_then(|v| v.as_str()).map(|s| s.to_string()) + } else { + None + }; + + let nix_id = act.id as i64; + let parent_id = act.parent_id as i64; + let event_type_i64 = act.event_type as i64; + let start_time_str = act.start_time.to_rfc3339(); let end_time_str = end_time.to_rfc3339(); - let total_bytes = act.total_bytes as i64; + let total_bytes_i64 = act.total_bytes as i64; sqlx::query!( - "INSERT INTO events (nix_id, parent_id, event_type, text, drv_path, cache_url, start_time, end_time, duration_ms, total_bytes) + "INSERT INTO events (nix_id, parent_id, event_type, text, drv_path, cache_url, start_time, end_time, duration_ms, total_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - nid, pid, ety, act.text, drv_path, cache_url, start_time, end_time_str, duration, total_bytes + nix_id, parent_id, event_type_i64, act.text, drv_path, cache_url, + start_time_str, end_time_str, duration, total_bytes_i64 ) .execute(&s.pool) .await?; } } - _ => {} + _ => { + info!(action = %event.action, id = event.id, "unknown action"); + } } Ok(()) } async fn collect_stats(pool: Pool) -> Result { - let by_type = sqlx::query_as!( - TypeStat, - "SELECT event_type as \"event_type!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\" - FROM events GROUP BY event_type ORDER BY total_ms DESC" - ).fetch_all(&pool).await?; - - let io = sqlx::query!( + // Single pass over events for the three summary counters. + // CAST(COUNT FILTER) returns i32, COALESCE(SUM, 0) returns i32 in sqlx SQLite. + let summary = sqlx::query!( "SELECT - CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"cache_hits!\", - CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"cache_misses!\", - CAST(COALESCE(SUM(total_bytes), 0) AS INTEGER) as \"total_bytes!\", - CAST(COALESCE(SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)), 0) AS INTEGER) as \"net_ms!\" + CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"build_count!\", + COALESCE(SUM(duration_ms) FILTER (WHERE event_type = 105), 0) as \"build_total_ms!\", + CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"subst_count!\", + COALESCE(SUM(duration_ms) FILTER (WHERE event_type = 108), 0) as \"subst_total_ms!\", + COALESCE(SUM(total_bytes) FILTER (WHERE event_type = 101), 0) as \"download_bytes!\", + COALESCE(SUM(duration_ms) FILTER (WHERE event_type = 101), 0) as \"download_ms!\" FROM events" ).fetch_one(&pool).await?; - let by_phase = sqlx::query_as!( - PhaseStat, - "SELECT phase_name as \"name!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\" - FROM phases GROUP BY phase_name ORDER BY total_ms DESC LIMIT 8" + // Top 10 slowest local builds. + let slowest_builds = sqlx::query_as!( + SlowBuild, + "SELECT duration_ms as \"duration_ms!\", drv_path, text + FROM events WHERE event_type = 105 + ORDER BY duration_ms DESC LIMIT 10" ).fetch_all(&pool).await?; - let slowest = sqlx::query_as!( - SlowEvent, - "SELECT duration_ms as \"duration_ms!\", event_type as \"event_type!\", drv_path, text - FROM events - WHERE event_type IN (105, 108, 102, 112) - ORDER BY duration_ms DESC LIMIT 10" + // Per-cache average query latency (QueryPathInfo = 109 stores cache_url). + let cache_latency = sqlx::query_as!( + CacheStat, + "SELECT cache_url as \"cache_url!\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\", CAST(COUNT(*) AS INTEGER) as \"count!\" + FROM events WHERE cache_url IS NOT NULL + GROUP BY cache_url ORDER BY 2 DESC" ).fetch_all(&pool).await?; Ok(Stats { - by_type, - cache_hits: io.cache_hits, - cache_misses: io.cache_misses, - total_bytes: io.total_bytes, - net_ms: io.net_ms, - by_phase, - slowest, + build_count: summary.build_count as i64, + build_total_ms: summary.build_total_ms as i64, + subst_count: summary.subst_count as i64, + subst_total_ms: summary.subst_total_ms as i64, + download_bytes: summary.download_bytes as i64, + download_ms: summary.download_ms as i64, + slowest_builds, + cache_latency, }) } -fn display_stats(stats: Stats) { - println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); - println!("{}", "-".repeat(57)); - for row in stats.by_type { - let label = format!("{:?}", ActivityType::from(row.event_type)); - let total_s = row.total_ms.unwrap_or(0) as f64 / 1000.0; - println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", label, row.count, row.avg_ms / 1000.0, total_s); +fn fmt_ms(ms: i64) -> String { + if ms < 1000 { + format!("{}ms", ms) + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + format!("{}m{:.1}s", ms / 60_000, (ms % 60_000) as f64 / 1000.0) } +} - let total_io = stats.cache_hits + stats.cache_misses; - let hit_rate = if total_io > 0 { stats.cache_hits as f64 / total_io as f64 * 100.0 } else { 0.0 }; - let mb = stats.total_bytes as f64 / 1024.0 / 1024.0; - let speed = if stats.net_ms > 0 { mb / (stats.net_ms as f64 / 1000.0) } else { 0.0 }; - println!("\n--- PERFORMANCE ---"); - println!("{:<18} {:>10.1}%", "Cache Hit Rate:", hit_rate); - println!("{:<18} {:>10.2} MB", "Total Data:", mb); - println!("{:<18} {:>10.2} MB/s", "Avg Net Speed:", speed); - - if !stats.by_phase.is_empty() { - println!("\n{:<22} {:>8} {:>12} {:>12}", "BUILD PHASE", "COUNT", "AVG TIME", "TOTAL TIME"); - println!("{}", "-".repeat(57)); - for row in stats.by_phase { - let total_s = row.total_ms.unwrap_or(0) as f64 / 1000.0; - println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", row.name, row.count, row.avg_ms / 1000.0, total_s); - } +// Strip /nix/store/- prefix to get the human-readable package name. +fn drv_name(path: &str) -> &str { + let s = path.strip_prefix("/nix/store/").unwrap_or(path); + // hash is 32 hex chars followed by '-' + if s.len() > 33 && s.chars().take(32).all(|c| c.is_ascii_hexdigit()) && s.as_bytes()[32] == b'-' { + &s[33..] + } else { + s } +} - if !stats.slowest.is_empty() { - println!("\n{:<12} {:<10} {}", "DURATION", "TYPE", "DERIVATION / TASK"); - println!("{}", "-".repeat(57)); - for row in stats.slowest { - let label = match row.event_type { - 105 => "Build", - 108 => "Subst", - 112 => "Fetch", - 102 => "Realise", - _ => "Other", - }; - let path = row.drv_path.or(row.text).unwrap_or_default(); - let name = path.strip_prefix("/nix/store/").unwrap_or(&path); - println!("{:>10.2}s {:<10} {}", row.duration_ms as f64 / 1000.0, label, name); +fn display_stats(stats: Stats) { + let build_avg = if stats.build_count > 0 { stats.build_total_ms / stats.build_count } else { 0 }; + let subst_avg = if stats.subst_count > 0 { stats.subst_total_ms / stats.subst_count } else { 0 }; + let mb = stats.download_bytes as f64 / 1_048_576.0; + let dl_speed = if stats.download_ms > 0 { mb / (stats.download_ms as f64 / 1000.0) } else { 0.0 }; + + println!("{:<14} {:>6} total {:>9} avg {:>9}", "built", stats.build_count, fmt_ms(stats.build_total_ms), fmt_ms(build_avg)); + println!("{:<14} {:>6} total {:>9} avg {:>9}", "substituted", stats.subst_count, fmt_ms(stats.subst_total_ms), fmt_ms(subst_avg)); + println!("{:<14} {:>8.1} MB avg {:>6.1} MB/s", "downloaded", mb, dl_speed); + + if !stats.slowest_builds.is_empty() { + println!(); + for row in &stats.slowest_builds { + let path = row.drv_path.as_deref().or(row.text.as_deref()).unwrap_or("?"); + println!("{:>9} {}", fmt_ms(row.duration_ms), drv_name(path)); } } - println!(); + if !stats.cache_latency.is_empty() { + let url_w = stats.cache_latency.iter().map(|r| r.cache_url.len()).max().unwrap_or(0); + println!(); + for row in &stats.cache_latency { + println!("{:7} {:>6} queries", row.cache_url, fmt_ms(row.avg_ms as i64), row.count, width = url_w); + } + } }