Skip to main content

netget/client/spark/
decode.rs

1//! A monitoring-API response → the event the model reads.
2//!
3//! Spark answers every resource with JSON — a bare array for lists, an object for one entry —
4//! and each entry carries far more than a model can use (a stage brings ~60 fields, an
5//! executor a nested memory-metrics map). So each resource is **projected** onto a fixed list
6//! of fields, and nothing the server adds beyond them reaches the model.
7//!
8//! | resource | fields kept |
9//! |---|---|
10//! | application | [`APPLICATION_FIELDS`], `attempts` (at most [`MAX_ATTEMPTS`]) each [`ATTEMPT_FIELDS`] |
11//! | job | [`JOB_FIELDS`] |
12//! | stage attempt | [`STAGE_FIELDS`] |
13//! | executor | [`EXECUTOR_FIELDS`] |
14//! | SQL execution | [`SQL_FIELDS`], and with a plan: `planDescription`, `nodes` as `{nodeId, nodeName}` |
15//! | task summary | `quantiles` and every metric list, one level of metric groups |
16//! | environment | `runtime`, `sparkProperties` as a map, the other sections counted |
17//!
18//! Every value kept is a scalar or a list of scalars. Text is stripped of control characters
19//! (it is a peer's, and it reaches a terminal) and cut at [`MAX_TEXT_BYTES`] —
20//! [`MAX_LONG_TEXT_BYTES`] for a failure reason or a plan, which are multi-line by nature. A
21//! list of ids or numbers keeps at most [`MAX_SCALAR_LIST`] entries.
22//!
23//! Before any of that, the body is bounded: at most `MAX_RESPONSE_BYTES` are read (while
24//! reading), and its bracket nesting is measured without recursion and refused past
25//! [`MAX_JSON_DEPTH`] before `serde_json` sees it. Spark's deepest answer, a task summary, nests
26//! four levels.
27
28use anyhow::{bail, ensure, Result};
29use serde_json::{json, Map, Value};
30
31use crate::utils::sanitize;
32use crate::utils::truncate::truncate_with_suffix;
33
34/// Bracket nesting accepted in a response. Spark's own deepest is 4.
35pub const MAX_JSON_DEPTH: usize = 32;
36/// A text field, single-line.
37pub const MAX_TEXT_BYTES: usize = 2048;
38/// A failure reason, an error message, a plan description.
39pub const MAX_LONG_TEXT_BYTES: usize = 8192;
40/// A list of ids or numbers (a job's stageIds, a stage's rddIds, a metric's quantiles).
41pub const MAX_SCALAR_LIST: usize = 1000;
42/// Attempts kept per application.
43pub const MAX_ATTEMPTS: usize = 20;
44/// Plan nodes kept per SQL execution.
45pub const MAX_PLAN_NODES: usize = 200;
46/// Metric lists kept in a task summary.
47pub const MAX_METRICS: usize = 100;
48
49pub const APPLICATION_FIELDS: &[&str] = &[
50    "id",
51    "name",
52    "coresGranted",
53    "maxCores",
54    "coresPerExecutor",
55    "memoryPerExecutorMB",
56];
57pub const ATTEMPT_FIELDS: &[&str] = &[
58    "attemptId",
59    "startTime",
60    "endTime",
61    "lastUpdated",
62    "duration",
63    "sparkUser",
64    "completed",
65    "appSparkVersion",
66];
67pub const JOB_FIELDS: &[&str] = &[
68    "jobId",
69    "name",
70    "description",
71    "submissionTime",
72    "completionTime",
73    "stageIds",
74    "jobGroup",
75    "jobTags",
76    "status",
77    "numTasks",
78    "numActiveTasks",
79    "numCompletedTasks",
80    "numSkippedTasks",
81    "numFailedTasks",
82    "numKilledTasks",
83    "numCompletedIndices",
84    "numActiveStages",
85    "numCompletedStages",
86    "numSkippedStages",
87    "numFailedStages",
88];
89pub const STAGE_FIELDS: &[&str] = &[
90    "stageId",
91    "attemptId",
92    "status",
93    "name",
94    "description",
95    "numTasks",
96    "numActiveTasks",
97    "numCompleteTasks",
98    "numFailedTasks",
99    "numKilledTasks",
100    "numCompletedIndices",
101    "submissionTime",
102    "firstTaskLaunchedTime",
103    "completionTime",
104    "failureReason",
105    "executorDeserializeTime",
106    "executorRunTime",
107    "executorCpuTime",
108    "resultSize",
109    "jvmGcTime",
110    "peakExecutionMemory",
111    "inputBytes",
112    "inputRecords",
113    "outputBytes",
114    "outputRecords",
115    "shuffleReadBytes",
116    "shuffleReadRecords",
117    "shuffleWriteBytes",
118    "shuffleWriteRecords",
119    "shuffleFetchWaitTime",
120    "shuffleWriteTime",
121    "memoryBytesSpilled",
122    "diskBytesSpilled",
123    "schedulingPool",
124    "rddIds",
125    "resourceProfileId",
126];
127pub const EXECUTOR_FIELDS: &[&str] = &[
128    "id",
129    "hostPort",
130    "isActive",
131    "rddBlocks",
132    "memoryUsed",
133    "diskUsed",
134    "totalCores",
135    "maxTasks",
136    "activeTasks",
137    "failedTasks",
138    "completedTasks",
139    "totalTasks",
140    "totalDuration",
141    "totalGCTime",
142    "totalInputBytes",
143    "totalShuffleRead",
144    "totalShuffleWrite",
145    "isExcluded",
146    "maxMemory",
147    "addTime",
148    "removeTime",
149    "removeReason",
150    "resourceProfileId",
151];
152pub const SQL_FIELDS: &[&str] = &[
153    "id",
154    "status",
155    "description",
156    "submissionTime",
157    "duration",
158    "runningJobIds",
159    "successJobIds",
160    "failedJobIds",
161    "queryId",
162    "errorMessage",
163    "rootExecutionId",
164];
165
166/// Fields whose text is multi-line by nature and gets the longer bound.
167const LONG_TEXT_FIELDS: &[&str] = &[
168    "failureReason",
169    "errorMessage",
170    "planDescription",
171    "removeReason",
172];
173
174/// Which resource a response is.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum Resource {
177    Applications,
178    Jobs,
179    Stages,
180    TaskSummary,
181    Executors,
182    Environment,
183    Sql,
184    Version,
185}
186
187impl Resource {
188    pub fn of(operation: &str) -> Option<Resource> {
189        Some(match operation {
190            "list_applications" | "get_application" => Resource::Applications,
191            "list_jobs" | "get_job" => Resource::Jobs,
192            "list_stages" | "get_stage" => Resource::Stages,
193            "get_task_summary" => Resource::TaskSummary,
194            "list_executors" => Resource::Executors,
195            "get_environment" => Resource::Environment,
196            "list_sql_executions" | "get_sql_execution" => Resource::Sql,
197            "get_version" => Resource::Version,
198            _ => return None,
199        })
200    }
201}
202
203/// The deepest bracket nesting in a JSON text, outside its strings. Iterative, so a hostile
204/// body cannot exhaust the stack measuring it.
205pub fn json_depth(bytes: &[u8]) -> usize {
206    let (mut depth, mut max, mut in_string, mut escaped) = (0usize, 0usize, false, false);
207    for &b in bytes {
208        if in_string {
209            match (escaped, b) {
210                (true, _) => escaped = false,
211                (false, b'\\') => escaped = true,
212                (false, b'"') => in_string = false,
213                _ => {}
214            }
215            continue;
216        }
217        match b {
218            b'"' => in_string = true,
219            b'[' | b'{' => {
220                depth += 1;
221                max = max.max(depth);
222            }
223            b']' | b'}' => depth = depth.saturating_sub(1),
224            _ => {}
225        }
226    }
227    max
228}
229
230/// Parse a body, refusing one that nests deeper than [`MAX_JSON_DEPTH`] before parsing it.
231pub fn parse(body: &[u8]) -> Result<Value> {
232    let depth = json_depth(body);
233    ensure!(
234        depth <= MAX_JSON_DEPTH,
235        "the response nests {depth} levels deep; at most {MAX_JSON_DEPTH} are read (Spark's own \
236         answers nest at most 4)"
237    );
238    serde_json::from_slice(body).map_err(|e| anyhow::anyhow!("the response is not JSON: {e}"))
239}
240
241/// A peer's text, made safe to show and bounded.
242pub fn text(s: &str, long: bool) -> String {
243    let clean = if long {
244        sanitize::multiline(s)
245    } else {
246        sanitize::line_field(s)
247    };
248    truncate_with_suffix(
249        &clean,
250        if long {
251            MAX_LONG_TEXT_BYTES
252        } else {
253            MAX_TEXT_BYTES
254        },
255        "...",
256    )
257}
258
259/// A scalar or a list of scalars, bounded; anything else is dropped (`None`).
260fn scalar(key: &str, v: &Value) -> Option<Value> {
261    match v {
262        Value::Null | Value::Bool(_) | Value::Number(_) => Some(v.clone()),
263        Value::String(s) => Some(Value::String(text(s, LONG_TEXT_FIELDS.contains(&key)))),
264        Value::Array(items) => Some(Value::Array(
265            items
266                .iter()
267                .filter(|x| !matches!(x, Value::Array(_) | Value::Object(_)))
268                .take(MAX_SCALAR_LIST)
269                .map(|x| match x {
270                    Value::String(s) => Value::String(text(s, false)),
271                    other => other.clone(),
272                })
273                .collect(),
274        )),
275        Value::Object(_) => None,
276    }
277}
278
279/// `fields` of `entry`, each a scalar or a list of scalars.
280pub fn project(entry: &Value, fields: &[&str]) -> Value {
281    let mut out = Map::new();
282    if let Some(obj) = entry.as_object() {
283        for key in fields {
284            if let Some(v) = obj.get(*key).and_then(|v| scalar(key, v)) {
285                out.insert((*key).to_string(), v);
286            }
287        }
288    }
289    Value::Object(out)
290}
291
292fn application(entry: &Value) -> Value {
293    let mut out = project(entry, APPLICATION_FIELDS);
294    let attempts: Vec<Value> = entry
295        .get("attempts")
296        .and_then(Value::as_array)
297        .map(|a| {
298            a.iter()
299                .take(MAX_ATTEMPTS)
300                .map(|x| project(x, ATTEMPT_FIELDS))
301                .collect()
302        })
303        .unwrap_or_default();
304    out["attempts"] = Value::Array(attempts);
305    out
306}
307
308fn sql_execution(entry: &Value, with_plan: bool) -> Value {
309    let mut out = project(entry, SQL_FIELDS);
310    if with_plan {
311        if let Some(plan) = entry.get("planDescription").and_then(Value::as_str) {
312            out["planDescription"] = json!(text(plan, true));
313        }
314        let nodes = entry.get("nodes").and_then(Value::as_array);
315        out["nodes"] = Value::Array(
316            nodes
317                .map(|n| {
318                    n.iter()
319                        .take(MAX_PLAN_NODES)
320                        .map(|x| project(x, &["nodeId", "nodeName"]))
321                        .collect()
322                })
323                .unwrap_or_default(),
324        );
325        out["node_count"] = json!(nodes.map(Vec::len).unwrap_or(0));
326    }
327    out
328}
329
330/// The entries of a list-or-one response, each required to be an object.
331fn entries<'a>(value: &'a Value, what: &str) -> Result<&'a [Value]> {
332    match value {
333        Value::Array(items) => {
334            ensure!(
335                items.iter().all(Value::is_object),
336                "Spark answers {what} as an array of objects; this array holds something else"
337            );
338            Ok(items.as_slice())
339        }
340        Value::Object(_) => Ok(std::slice::from_ref(value)),
341        _ => bail!("Spark answers {what} as a JSON array or object; this is neither"),
342    }
343}
344
345/// The first `max_items` of `items`, each through `f`, with `count`/`total`/`truncated` set.
346fn take(
347    out: &mut Map<String, Value>,
348    key: &str,
349    items: &[Value],
350    max_items: usize,
351    f: impl Fn(&Value) -> Value,
352) {
353    let total = items.len();
354    let kept: Vec<Value> = items.iter().take(max_items).map(f).collect();
355    out.insert("count".into(), json!(kept.len()));
356    out.insert("total".into(), json!(total));
357    out.insert("truncated".into(), json!(total > max_items));
358    out.insert(key.into(), Value::Array(kept));
359}
360
361/// The event payload for `resource`, on top of the validated request's `context`.
362pub fn decode(
363    resource: Resource,
364    operation: &str,
365    value: &Value,
366    max_items: usize,
367    context: &Map<String, Value>,
368) -> Result<Value> {
369    let mut out = context.clone();
370    out.insert("operation".into(), json!(operation));
371    match resource {
372        Resource::Applications => {
373            let items = entries(value, "applications")?;
374            take(&mut out, "applications", items, max_items, application);
375        }
376        Resource::Jobs => {
377            let items = entries(value, "jobs")?;
378            take(&mut out, "jobs", items, max_items, |x| {
379                project(x, JOB_FIELDS)
380            });
381        }
382        Resource::Stages => {
383            let items = entries(value, "stages")?;
384            take(&mut out, "stages", items, max_items, |x| {
385                project(x, STAGE_FIELDS)
386            });
387        }
388        Resource::Executors => {
389            let items = entries(value, "executors")?;
390            take(&mut out, "executors", items, max_items, |x| {
391                project(x, EXECUTOR_FIELDS)
392            });
393        }
394        Resource::Sql => {
395            let with_plan = operation == "get_sql_execution";
396            let items = entries(value, "SQL executions")?;
397            take(&mut out, "executions", items, max_items, |x| {
398                sql_execution(x, with_plan)
399            });
400        }
401        Resource::TaskSummary => {
402            let obj = value
403                .as_object()
404                .ok_or_else(|| anyhow::anyhow!("Spark answers a task summary as an object"))?;
405            let quantiles = obj
406                .get("quantiles")
407                .and_then(|q| scalar("quantiles", q))
408                .ok_or_else(|| anyhow::anyhow!("the task summary carries no quantiles"))?;
409            let mut metrics = Map::new();
410            for (key, v) in obj.iter().filter(|(k, _)| k.as_str() != "quantiles") {
411                if metrics.len() >= MAX_METRICS {
412                    break;
413                }
414                match v {
415                    Value::Array(_) => {
416                        if let Some(list) = scalar(key, v) {
417                            metrics.insert(key.clone(), list);
418                        }
419                    }
420                    // One level of grouping (inputMetrics, shuffleReadMetrics, ...); a group
421                    // nested inside a group is not read.
422                    Value::Object(group) => {
423                        let mut inner = Map::new();
424                        for (k, list) in group.iter().take(MAX_METRICS) {
425                            if let Value::Array(_) = list {
426                                if let Some(list) = scalar(k, list) {
427                                    inner.insert(k.clone(), list);
428                                }
429                            }
430                        }
431                        metrics.insert(key.clone(), Value::Object(inner));
432                    }
433                    _ => {}
434                }
435            }
436            let n = metrics.len();
437            out.insert("quantiles".into(), quantiles);
438            out.insert("metrics".into(), Value::Object(metrics));
439            out.insert("count".into(), json!(n));
440            out.insert("total".into(), json!(n));
441            out.insert("truncated".into(), json!(false));
442        }
443        Resource::Environment => {
444            let obj = value
445                .as_object()
446                .ok_or_else(|| anyhow::anyhow!("Spark answers an environment as an object"))?;
447            let runtime = obj
448                .get("runtime")
449                .map(|r| project(r, &["javaVersion", "javaHome", "scalaVersion"]))
450                .unwrap_or_else(|| json!({}));
451            let props = obj
452                .get("sparkProperties")
453                .and_then(Value::as_array)
454                .cloned()
455                .unwrap_or_default();
456            let total = props.len();
457            let mut map = Map::new();
458            for pair in props.iter() {
459                if map.len() >= max_items {
460                    break;
461                }
462                if let (Some(k), Some(v)) = (
463                    pair.get(0).and_then(Value::as_str),
464                    pair.get(1).and_then(Value::as_str),
465                ) {
466                    map.insert(text(k, false), json!(text(v, false)));
467                }
468            }
469            let count_of = |key: &str| obj.get(key).and_then(Value::as_array).map_or(0, Vec::len);
470            out.insert("runtime".into(), runtime);
471            out.insert("count".into(), json!(map.len()));
472            out.insert("total".into(), json!(total));
473            out.insert("truncated".into(), json!(total > map.len()));
474            out.insert("spark_properties".into(), Value::Object(map));
475            out.insert(
476                "system_property_count".into(),
477                json!(count_of("systemProperties")),
478            );
479            out.insert(
480                "hadoop_property_count".into(),
481                json!(count_of("hadoopProperties")),
482            );
483            out.insert(
484                "classpath_entry_count".into(),
485                json!(count_of("classpathEntries")),
486            );
487        }
488        Resource::Version => {
489            let v = value
490                .get("spark")
491                .and_then(Value::as_str)
492                .ok_or_else(|| anyhow::anyhow!("the version answer carries no \"spark\" field"))?;
493            out.insert("spark_version".into(), json!(text(v, false)));
494        }
495    }
496    Ok(Value::Object(out))
497}