Skip to main content

spark_connect/
profiler.rs

1//! Client-side profiler collector for UDF and plan profiling results.
2//!
3//! Mirrors `pyspark.sql.connect.profiler.ConnectProfilerCollector`. Accumulates profile
4//! results observed across multiple `ExecutePlanResponse`s during the session and provides
5//! methods to show, dump, and clear profile data.
6//!
7//! Profile data is populated by the server only when UDF profiling is enabled via
8//! `spark.python.profile*` or `spark.sql.pyspark.udf.profiler` configuration.
9
10use std::collections::HashMap;
11use std::sync::Mutex;
12
13use spark_connect_core::error::Result;
14use spark_connect_proto as proto;
15
16/// Format a protobuf Literal value as a string.
17fn format_literal(literal: &proto::expression::Literal) -> String {
18    use proto::expression::literal::LiteralType;
19
20    match &literal.literal_type {
21        Some(LiteralType::Null(_)) => "null".to_string(),
22        Some(LiteralType::Boolean(b)) => b.to_string(),
23        Some(LiteralType::Byte(b)) => b.to_string(),
24        Some(LiteralType::Short(s)) => s.to_string(),
25        Some(LiteralType::Integer(i)) => i.to_string(),
26        Some(LiteralType::Long(l)) => l.to_string(),
27        Some(LiteralType::Float(f)) => f.to_string(),
28        Some(LiteralType::Double(d)) => d.to_string(),
29        Some(LiteralType::Decimal(_)) => "decimal".to_string(),
30        Some(LiteralType::String(s)) => s.clone(),
31        Some(LiteralType::Binary(b)) => format!("<binary: {} bytes>", b.len()),
32        Some(LiteralType::CalendarInterval(_)) => "calendar_interval".to_string(),
33        Some(LiteralType::YearMonthInterval(_)) => "year_month_interval".to_string(),
34        Some(LiteralType::DayTimeInterval(_)) => "day_time_interval".to_string(),
35        Some(LiteralType::Date(_)) => "date".to_string(),
36        Some(LiteralType::Timestamp(_)) => "timestamp".to_string(),
37        Some(LiteralType::TimestampNtz(_)) => "timestamp_ntz".to_string(),
38        Some(LiteralType::Time(_)) => "time".to_string(),
39        Some(LiteralType::TimestampNtzNanos(_)) => "timestamp_ntz_nanos".to_string(),
40        Some(LiteralType::TimestampLtzNanos(_)) => "timestamp_ltz_nanos".to_string(),
41        Some(LiteralType::Map(_)) => "map".to_string(),
42        Some(LiteralType::Array(_)) => "array".to_string(),
43        Some(LiteralType::Struct(_)) => "struct".to_string(),
44        Some(LiteralType::SpecializedArray(_)) => "specialized_array".to_string(),
45        None => "unknown".to_string(),
46    }
47}
48
49/// Stores collected profile data for a single profiler ID.
50#[derive(Debug, Clone)]
51struct ProfileResult {
52    /// The accumulated profile data (typically a string representation).
53    pub data: String,
54    /// Metadata about the profile (e.g., profile type, timestamp). Collected from the
55    /// profiler responses (see `collect_profiles`) for parity with pyspark; retained for
56    /// a future `show()`/`dump()` that surfaces it, hence not read yet.
57    #[allow(dead_code)]
58    pub metadata: HashMap<String, String>,
59}
60
61/// Client-side collector for accumulated profiler results.
62///
63/// Accumulates UDF and plan profile results across multiple query executions
64/// and provides access via `show()`, `dump()`, and `clear()` methods.
65#[derive(Debug, Clone)]
66pub struct ProfilerCollector {
67    /// Profile results keyed by profiler ID.
68    profiles: std::sync::Arc<Mutex<HashMap<i64, ProfileResult>>>,
69}
70
71impl ProfilerCollector {
72    /// Create a new profiler collector.
73    pub fn new() -> Self {
74        ProfilerCollector {
75            profiles: std::sync::Arc::new(Mutex::new(HashMap::new())),
76        }
77    }
78
79    /// Accumulate a profile result. Called internally during query execution.
80    pub(crate) fn accumulate_profile(
81        &self,
82        id: i64,
83        data: String,
84        metadata: HashMap<String, String>,
85    ) {
86        let mut profiles = self.profiles.lock().unwrap();
87        profiles.insert(id, ProfileResult { data, metadata });
88    }
89
90    /// Accumulate observed metrics from a server response.
91    pub(crate) fn accumulate_observed_metrics(
92        &self,
93        metrics: &[proto::execute_plan_response::ObservedMetrics],
94    ) {
95        for metric in metrics {
96            // Use the metric name and plan_id as profile identifier
97            let id = metric.plan_id;
98            let mut data = String::new();
99            let mut metadata = HashMap::new();
100
101            // Pair up keys and values if available
102            let num_values = metric.values.len();
103            for i in 0..num_values {
104                let key = if i < metric.keys.len() {
105                    metric.keys[i].clone()
106                } else {
107                    format!("value_{}", i)
108                };
109
110                // Try to extract value as string from the Literal
111                let value_str = format_literal(&metric.values[i]);
112
113                metadata.insert(key.clone(), value_str.clone());
114                // Accumulate all metric values into data
115                if !data.is_empty() {
116                    data.push('\n');
117                }
118                data.push_str(&format!("{}: {}", key, value_str));
119            }
120
121            // Include the metric name and plan_id in the data
122            if !data.is_empty() {
123                data.insert_str(0, &format!("name: {}, plan_id: {}\n", metric.name, id));
124            }
125
126            // Store the profile result if we have any data or metadata
127            if !data.is_empty() || !metadata.is_empty() {
128                self.accumulate_profile(id, data, metadata);
129            }
130        }
131    }
132
133    /// Show the profile results for a given profiler ID, or all profiles if ID is None.
134    ///
135    /// Returns a formatted string representation of the profile data.
136    pub fn show(&self, id: Option<i64>) -> String {
137        let profiles = self.profiles.lock().unwrap();
138
139        match id {
140            Some(profile_id) => profiles
141                .get(&profile_id)
142                .map(|p| p.data.clone())
143                .unwrap_or_else(|| format!("No profile data for id: {}", profile_id)),
144            None => {
145                // Show all profiles
146                if profiles.is_empty() {
147                    "No profile data collected".to_string()
148                } else {
149                    let mut result = String::new();
150                    for (id, profile) in profiles.iter() {
151                        result.push_str(&format!("=== Profile {} ===\n", id));
152                        result.push_str(&profile.data);
153                        result.push('\n');
154                    }
155                    result
156                }
157            }
158        }
159    }
160
161    /// Write profile results to a file.
162    ///
163    /// For a given profiler ID (or all profiles if ID is None), writes the profile data to the
164    /// specified file path.
165    pub fn dump(&self, id: Option<i64>, path: &str) -> Result<()> {
166        let data = self.show(id);
167
168        std::fs::write(path, data).map_err(|e| {
169            spark_connect_core::error::SparkError::connect_msg(format!(
170                "Failed to write profile to {}: {}",
171                path, e
172            ))
173        })?;
174
175        Ok(())
176    }
177
178    /// Clear collected profile results for a given ID, or all profiles if ID is None.
179    pub fn clear(&self, id: Option<i64>) {
180        let mut profiles = self.profiles.lock().unwrap();
181
182        match id {
183            Some(profile_id) => {
184                profiles.remove(&profile_id);
185            }
186            None => {
187                profiles.clear();
188            }
189        }
190    }
191
192    /// Get the number of collected profiles.
193    #[cfg(test)]
194    pub(crate) fn count(&self) -> usize {
195        self.profiles.lock().unwrap().len()
196    }
197}
198
199impl Default for ProfilerCollector {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_profiler_collector_accumulate_and_show() {
211        let collector = ProfilerCollector::new();
212
213        // Accumulate a profile
214        let mut metadata = HashMap::new();
215        metadata.insert("type".to_string(), "udf".to_string());
216        collector.accumulate_profile(1, "profile data 1".to_string(), metadata);
217
218        // Show profile by ID
219        let result = collector.show(Some(1));
220        assert_eq!(result, "profile data 1");
221
222        // Show all profiles
223        let all = collector.show(None);
224        assert!(all.contains("profile data 1"));
225        assert!(all.contains("=== Profile 1 ==="));
226    }
227
228    #[test]
229    fn test_profiler_collector_show_empty() {
230        let collector = ProfilerCollector::new();
231
232        let result = collector.show(None);
233        assert_eq!(result, "No profile data collected");
234
235        let result = collector.show(Some(99));
236        assert_eq!(result, "No profile data for id: 99");
237    }
238
239    #[test]
240    fn test_profiler_collector_clear() {
241        let collector = ProfilerCollector::new();
242
243        let metadata = HashMap::new();
244        collector.accumulate_profile(1, "profile 1".to_string(), metadata.clone());
245        collector.accumulate_profile(2, "profile 2".to_string(), metadata);
246
247        assert_eq!(collector.count(), 2);
248
249        // Clear one profile
250        collector.clear(Some(1));
251        assert_eq!(collector.count(), 1);
252
253        // Clear all
254        collector.clear(None);
255        assert_eq!(collector.count(), 0);
256    }
257
258    #[test]
259    fn test_profiler_collector_dump_and_read() {
260        let collector = ProfilerCollector::new();
261        let temp_dir = std::env::temp_dir();
262        let path = temp_dir.join("test_profile_dump.txt");
263
264        let metadata = HashMap::new();
265        collector.accumulate_profile(1, "test profile data".to_string(), metadata);
266
267        // Dump to file
268        let result = collector.dump(Some(1), path.to_str().unwrap());
269        assert!(result.is_ok());
270
271        // Read and verify
272        let contents = std::fs::read_to_string(&path).unwrap();
273        assert_eq!(contents, "test profile data");
274
275        // Cleanup
276        let _ = std::fs::remove_file(path);
277    }
278
279    #[test]
280    fn test_profiler_collector_accumulate_observed_metrics() {
281        let collector = ProfilerCollector::new();
282
283        // Create a mock observed metrics
284        let mut metric = proto::execute_plan_response::ObservedMetrics::default();
285        metric.name = "udf_profile".to_string();
286        metric.plan_id = 1;
287        metric.keys = vec!["udf_time_ms".to_string(), "python_calls".to_string()];
288
289        // Create literal values
290        metric.values.push(proto::expression::Literal {
291            literal_type: Some(proto::expression::literal::LiteralType::Long(150)),
292            data_type: None,
293        });
294        metric.values.push(proto::expression::Literal {
295            literal_type: Some(proto::expression::literal::LiteralType::Long(42)),
296            data_type: None,
297        });
298
299        collector.accumulate_observed_metrics(&[metric]);
300
301        // Verify the profile was accumulated
302        let result = collector.show(Some(1));
303        assert!(result.contains("udf_time_ms: 150"));
304        assert!(result.contains("python_calls: 42"));
305    }
306
307    #[test]
308    fn format_literal_covers_all_variants() {
309        use proto::expression::literal::{self, LiteralType};
310        let lit = |lt: LiteralType| proto::expression::Literal {
311            literal_type: Some(lt),
312            data_type: None,
313        };
314        assert_eq!(
315            format_literal(&lit(LiteralType::Null(proto::DataType::default()))),
316            "null"
317        );
318        assert_eq!(format_literal(&lit(LiteralType::Boolean(true))), "true");
319        assert_eq!(format_literal(&lit(LiteralType::Byte(1))), "1");
320        assert_eq!(format_literal(&lit(LiteralType::Short(2))), "2");
321        assert_eq!(format_literal(&lit(LiteralType::Integer(3))), "3");
322        assert_eq!(format_literal(&lit(LiteralType::Long(4))), "4");
323        assert_eq!(format_literal(&lit(LiteralType::Float(1.5))), "1.5");
324        assert_eq!(format_literal(&lit(LiteralType::Double(2.5))), "2.5");
325        assert_eq!(
326            format_literal(&lit(LiteralType::Decimal(literal::Decimal::default()))),
327            "decimal"
328        );
329        assert_eq!(
330            format_literal(&lit(LiteralType::String("hi".to_string()))),
331            "hi"
332        );
333        assert!(
334            format_literal(&lit(LiteralType::Binary(vec![1u8, 2, 3].into()))).contains("3 bytes")
335        );
336        assert_eq!(
337            format_literal(&lit(LiteralType::CalendarInterval(
338                literal::CalendarInterval::default()
339            ))),
340            "calendar_interval"
341        );
342        assert_eq!(
343            format_literal(&lit(LiteralType::YearMonthInterval(0))),
344            "year_month_interval"
345        );
346        assert_eq!(
347            format_literal(&lit(LiteralType::DayTimeInterval(0))),
348            "day_time_interval"
349        );
350        assert_eq!(format_literal(&lit(LiteralType::Date(0))), "date");
351        assert_eq!(format_literal(&lit(LiteralType::Timestamp(0))), "timestamp");
352        assert_eq!(
353            format_literal(&lit(LiteralType::TimestampNtz(0))),
354            "timestamp_ntz"
355        );
356        assert_eq!(
357            format_literal(&lit(LiteralType::Time(literal::Time::default()))),
358            "time"
359        );
360        assert_eq!(
361            format_literal(&lit(LiteralType::TimestampNtzNanos(
362                literal::TimestampNtzNanos::default()
363            ))),
364            "timestamp_ntz_nanos"
365        );
366        assert_eq!(
367            format_literal(&lit(LiteralType::TimestampLtzNanos(
368                literal::TimestampLtzNanos::default()
369            ))),
370            "timestamp_ltz_nanos"
371        );
372        assert_eq!(
373            format_literal(&lit(LiteralType::Map(literal::Map::default()))),
374            "map"
375        );
376        assert_eq!(
377            format_literal(&lit(LiteralType::Array(literal::Array::default()))),
378            "array"
379        );
380        assert_eq!(
381            format_literal(&lit(LiteralType::Struct(literal::Struct::default()))),
382            "struct"
383        );
384        assert_eq!(
385            format_literal(&lit(LiteralType::SpecializedArray(
386                literal::SpecializedArray::default()
387            ))),
388            "specialized_array"
389        );
390        // literal_type == None falls through to "unknown".
391        assert_eq!(
392            format_literal(&proto::expression::Literal {
393                literal_type: None,
394                data_type: None,
395            }),
396            "unknown"
397        );
398    }
399
400    #[test]
401    fn accumulate_observed_metrics_more_values_than_keys() {
402        // values longer than keys hits the `value_{i}` fallback key branch.
403        let collector = ProfilerCollector::new();
404        let mut metric = proto::execute_plan_response::ObservedMetrics::default();
405        metric.name = "m".to_string();
406        metric.plan_id = 7;
407        metric.keys = vec!["a".to_string()];
408        metric.values.push(proto::expression::Literal {
409            literal_type: Some(proto::expression::literal::LiteralType::Long(1)),
410            data_type: None,
411        });
412        metric.values.push(proto::expression::Literal {
413            literal_type: Some(proto::expression::literal::LiteralType::Long(2)),
414            data_type: None,
415        });
416        collector.accumulate_observed_metrics(&[metric]);
417        let shown = collector.show(Some(7));
418        assert!(shown.contains("a: 1"));
419        assert!(shown.contains("value_1: 2"));
420    }
421
422    #[test]
423    fn dump_to_unwritable_path_errors() {
424        let collector = ProfilerCollector::new();
425        collector.accumulate_profile(1, "data".to_string(), HashMap::new());
426        // A path under a non-existent directory cannot be written -> Err branch.
427        let res = collector.dump(Some(1), "/nonexistent_dir_zzz_12345/profile.txt");
428        assert!(res.is_err());
429    }
430
431    #[test]
432    fn default_constructs_empty_collector() {
433        let collector = ProfilerCollector::default();
434        assert_eq!(collector.count(), 0);
435    }
436}