Skip to content

Commit 1f0746d

Browse files
fix(planner): fixed bug in Elastic query processor where it wasn't using data_ingestion_interval_ms (#457)
1 parent 63422c3 commit 1f0746d

3 files changed

Lines changed: 187 additions & 18 deletions

File tree

asap-common/dependencies/rs/elastic_dsl_utilities/src/ast_parsing/extract_info.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ pub fn walk_ast_and_extract_info(ast: &dsl::Search) -> Option<ElasticDSLQueryInf
2424
// Extract information from the bool query
2525
walk_bool_query_and_extract_info(&bool_query)
2626
}
27+
Some(dsl::Query::Json(json_query)) => {
28+
// Serde's untagged enum deserialization falls through to Json when typed
29+
// deserialization fails. Extract predicates from the raw JSON structure.
30+
walk_json_query_and_extract_info(&json_query.0)
31+
}
2732
Some(other) => {
2833
// Predicates may just be specified directly without enclosing bool context.
2934
if let Some(predicate) = extract_predicates_from_query(&other) {
@@ -44,6 +49,57 @@ pub fn walk_ast_and_extract_info(ast: &dsl::Search) -> Option<ElasticDSLQueryInf
4449
))
4550
}
4651

52+
fn walk_json_query_and_extract_info(json: &serde_json::Value) -> Vec<Predicate> {
53+
let Some(filter) = json
54+
.get("bool")
55+
.and_then(|b| b.get("filter"))
56+
.and_then(|f| f.as_array())
57+
else {
58+
return Vec::new();
59+
};
60+
filter
61+
.iter()
62+
.filter_map(extract_predicate_from_json)
63+
.collect()
64+
}
65+
66+
fn extract_predicate_from_json(json: &serde_json::Value) -> Option<Predicate> {
67+
if let Some(range_obj) = json.get("range").and_then(|v| v.as_object()) {
68+
let (field, bounds) = range_obj.iter().next()?;
69+
let field = strip_keyword_suffix(field).to_owned();
70+
let gte = bounds
71+
.get("gte")
72+
.and_then(|v| v.as_str())
73+
.map(|s| TermValue::String(s.to_owned()));
74+
let lte = bounds
75+
.get("lte")
76+
.and_then(|v| v.as_str())
77+
.map(|s| TermValue::String(s.to_owned()));
78+
return Some(Predicate::Range { field, gte, lte });
79+
}
80+
if let Some(term_obj) = json.get("term").and_then(|v| v.as_object()) {
81+
let (field, value_obj) = term_obj.iter().next()?;
82+
let field = strip_keyword_suffix(field).to_owned();
83+
let raw = value_obj.get("value").unwrap_or(value_obj);
84+
let value = match raw {
85+
serde_json::Value::String(s) => TermValue::String(s.clone()),
86+
serde_json::Value::Bool(b) => TermValue::Boolean(*b),
87+
serde_json::Value::Number(n) => {
88+
if let Some(u) = n.as_u64() {
89+
TermValue::UnsignedInt(u)
90+
} else if let Some(i) = n.as_i64() {
91+
TermValue::Int(i)
92+
} else {
93+
TermValue::Float(n.as_f64()?)
94+
}
95+
}
96+
_ => return None,
97+
};
98+
return Some(Predicate::Term { field, value });
99+
}
100+
None
101+
}
102+
47103
fn walk_bool_query_and_extract_info(bool_query: &dsl::BoolQuery) -> Vec<Predicate> {
48104
// Placeholder for walking the filter context of the AST and extracting relevant information
49105
// This would involve traversing the filter nodes and applying logic to determine label filters, time ranges, etc.

asap-planner-rs/src/planner/elastic_dsl.rs

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ use indexmap::IndexSet;
2020
pub struct ElasticSingleQueryProcessor {
2121
query_string: String,
2222
t_repeat_ms: u64,
23-
#[allow(dead_code)]
2423
data_ingestion_interval_ms: u64,
2524
index_schema: ElasticIndexSchemaBuilder,
2625
#[allow(dead_code)]
@@ -66,9 +65,43 @@ impl ElasticSingleQueryProcessor {
6665
let (treatment_type, statistics) = get_elastic_statistics(&query_info.aggregation)?;
6766

6867
let t_repeat_ms = self.t_repeat_ms;
68+
69+
// Validate and resolve the time-range predicate before building configs.
70+
let time_field = self.index_schema.time_field.clone();
71+
let time_range = query_info
72+
.predicates
73+
.iter()
74+
.find(|p| match p {
75+
Predicate::Range { field, .. } => field == &time_field,
76+
_ => false,
77+
})
78+
.and_then(|p| range_query_to_time_range(p, 0));
79+
let (t_lookback_ms, window_size_ms) = match time_range {
80+
None => {
81+
return Err(ControllerError::UnsupportedElasticDSLQuery(
82+
"query must include a time-range predicate on the time field".to_string(),
83+
))
84+
}
85+
Some(tr) => {
86+
let duration = tr.duration_ms().unwrap_or(t_repeat_ms);
87+
if duration < self.data_ingestion_interval_ms {
88+
return Err(ControllerError::UnsupportedElasticDSLQuery(format!(
89+
"time-range duration {}ms is shorter than the data ingestion interval {}ms",
90+
duration, self.data_ingestion_interval_ms
91+
)));
92+
}
93+
// Spatial query: duration spans exactly one ingestion interval → use interval as window
94+
let window = if duration == self.data_ingestion_interval_ms {
95+
self.data_ingestion_interval_ms
96+
} else {
97+
t_repeat_ms
98+
};
99+
(duration, window)
100+
}
101+
};
69102
let window_cfg = IntermediateWindowConfig {
70-
window_size_ms: t_repeat_ms,
71-
slide_interval_ms: t_repeat_ms,
103+
window_size_ms,
104+
slide_interval_ms: window_size_ms,
72105
window_type: WindowType::Tumbling,
73106
};
74107

@@ -121,20 +154,6 @@ impl ElasticSingleQueryProcessor {
121154
)
122155
.map_err(ControllerError::ElasticDSLParse)?;
123156

124-
let time_field = self.index_schema.time_field.clone();
125-
let time_range = query_info
126-
.predicates
127-
.iter()
128-
.find(|p| match p {
129-
Predicate::Range { field, .. } => field == &time_field,
130-
_ => false,
131-
})
132-
.and_then(|p| range_query_to_time_range(p, 0));
133-
let t_lookback_ms = match time_range {
134-
Some(tr) => tr.duration_ms().unwrap_or(t_repeat_ms),
135-
None => t_repeat_ms,
136-
};
137-
138157
// Calculate cleanup param based on query's time window
139158
let cleanup_param = if self.cleanup_policy == CleanupPolicy::NoCleanup {
140159
None

asap-planner-rs/tests/elastic_dsl_integration.rs

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use asap_planner::{ElasticController, ElasticRuntimeOptions, PlannerOutput, StreamingEngine};
1+
use asap_planner::{
2+
ControllerError, ElasticController, ElasticRuntimeOptions, PlannerOutput, StreamingEngine,
3+
};
24
use asap_types::{QueryLanguage, SchemaConfig};
35
use std::io::Write;
46
use std::path::Path;
@@ -41,6 +43,23 @@ fn elastic_output(index: &str, time_field: &str, query: &str, t_repeat_ms: u64)
4143
elastic_output_with_interval(index, time_field, query, t_repeat_ms, 15_000)
4244
}
4345

46+
fn try_elastic_with_interval(
47+
index: &str,
48+
time_field: &str,
49+
query: &str,
50+
t_repeat_ms: u64,
51+
data_ingestion_interval_ms: u64,
52+
) -> Result<PlannerOutput, ControllerError> {
53+
let yaml = elastic_yaml(index, time_field, query, t_repeat_ms);
54+
let mut file = NamedTempFile::new().unwrap();
55+
file.write_all(yaml.as_bytes()).unwrap();
56+
let opts = ElasticRuntimeOptions {
57+
streaming_engine: StreamingEngine::Arroyo,
58+
data_ingestion_interval_ms,
59+
};
60+
ElasticController::from_file(Path::new(file.path()), opts)?.generate()
61+
}
62+
4463
fn elastic_output_with_interval(
4564
index: &str,
4665
time_field: &str,
@@ -493,3 +512,78 @@ fn sub_second_repetition_delay_ms() {
493512
assert_eq!(out.streaming_aggregation_count(), 2);
494513
assert!(out.all_tumbling_window_sizes_eq(500));
495514
}
515+
516+
// ── time-range validation ─────────────────────────────────────────────────────
517+
518+
fn time_range_query(duration: &str) -> String {
519+
format!(
520+
r#"{{
521+
"aggs": {{ "sum_cpu": {{ "sum": {{ "field": "cpu_usage" }} }} }},
522+
"query": {{ "bool": {{ "filter": [{{ "range": {{ "@timestamp": {{ "gte": "now-{duration}", "lte": "now" }} }} }}] }} }}
523+
}}"#
524+
)
525+
}
526+
527+
const QUERY_NO_TIME_RANGE: &str = r#"{
528+
"aggs": { "sum_cpu": { "sum": { "field": "cpu_usage" } } }
529+
}"#;
530+
531+
#[test]
532+
fn no_time_range_predicate_is_rejected() {
533+
let result = try_elastic_with_interval(
534+
"metrics",
535+
"\"@timestamp\"",
536+
QUERY_NO_TIME_RANGE,
537+
300_000,
538+
15_000,
539+
);
540+
assert!(matches!(
541+
result,
542+
Err(ControllerError::UnsupportedElasticDSLQuery(_))
543+
));
544+
}
545+
546+
#[test]
547+
fn time_range_shorter_than_ingestion_interval_is_rejected() {
548+
// data_ingestion_interval_ms = 15_000ms (15s), query range = 5s → duration < interval
549+
let result = try_elastic_with_interval(
550+
"metrics",
551+
"\"@timestamp\"",
552+
&time_range_query("5s"),
553+
300_000,
554+
15_000,
555+
);
556+
assert!(matches!(
557+
result,
558+
Err(ControllerError::UnsupportedElasticDSLQuery(_))
559+
));
560+
}
561+
562+
#[test]
563+
fn time_range_equal_to_ingestion_interval_uses_interval_as_window_size() {
564+
// data_ingestion_interval_ms = 15_000ms (15s), query range = 15s → Spatial: window = interval
565+
let out = try_elastic_with_interval(
566+
"metrics",
567+
"\"@timestamp\"",
568+
&time_range_query("15s"),
569+
300_000,
570+
15_000,
571+
)
572+
.unwrap();
573+
assert!(out.all_tumbling_window_sizes_eq(15_000));
574+
}
575+
576+
#[test]
577+
fn time_range_longer_than_ingestion_interval_uses_t_repeat_as_window_size() {
578+
// data_ingestion_interval_ms = 15_000ms (15s), query range = 5m > 15s → Temporal: window = t_repeat_ms
579+
let t_repeat_ms = 300_000;
580+
let out = try_elastic_with_interval(
581+
"metrics",
582+
"\"@timestamp\"",
583+
&time_range_query("5m"),
584+
t_repeat_ms,
585+
15_000,
586+
)
587+
.unwrap();
588+
assert!(out.all_tumbling_window_sizes_eq(t_repeat_ms));
589+
}

0 commit comments

Comments
 (0)