1use std::collections::HashMap;
16
17use chrono::{DateTime, Utc};
18use common_base::memory_limit::MemoryLimit;
19use datafusion::config::{ConfigEntry, ConfigExtension, ExtensionOptions};
20use serde::{Deserialize, Serialize};
21use session::context::QueryContextRef;
22use store_api::storage::RegionId;
23use table::metadata::TableId;
24
25use crate::error::{Error, InvalidQueryContextExtensionSnafu, Result};
26
27pub const FLOW_INCREMENTAL_AFTER_SEQS: &str = "flow.incremental_after_seqs";
28pub const FLOW_INCREMENTAL_MODE: &str = "flow.incremental_mode";
29pub const FLOW_RETURN_REGION_SEQ: &str = "flow.return_region_seq";
30pub const FLOW_SINK_TABLE_ID: &str = "flow.sink_table_id";
31pub const FLOW_SCHEDULED_TIME_MILLIS: &str = "flow.scheduled_time_millis";
35pub const QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN: &str =
37 "query.enable_remote_dynamic_filter_pushdown";
38
39pub const FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY: &str = "memtable_only";
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(default)]
44pub struct QueryOptions {
45 pub parallelism: usize,
47 pub allow_query_fallback: bool,
49 pub memory_pool_size: MemoryLimit,
53 #[serde(skip)]
55 pub enable_per_region_metrics: bool,
56}
57
58#[allow(clippy::derivable_impls)]
59impl Default for QueryOptions {
60 fn default() -> Self {
61 Self {
62 parallelism: 0,
63 allow_query_fallback: false,
64 memory_pool_size: MemoryLimit::default(),
65 enable_per_region_metrics: false,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum FlowIncrementalMode {
72 MemtableOnly,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Default)]
76pub struct FlowQueryExtensions {
77 pub incremental_after_seqs: Option<HashMap<u64, u64>>,
79 pub incremental_mode: Option<FlowIncrementalMode>,
81 pub return_region_seq: bool,
83 pub sink_table_id: Option<TableId>,
85}
86
87impl FlowQueryExtensions {
88 pub fn parse_flow_extensions(extensions: &HashMap<String, String>) -> Result<Option<Self>> {
94 let has_flow_context = extensions.contains_key(FLOW_INCREMENTAL_AFTER_SEQS)
95 || extensions.contains_key(FLOW_INCREMENTAL_MODE)
96 || extensions.contains_key(FLOW_RETURN_REGION_SEQ)
97 || extensions.contains_key(FLOW_SINK_TABLE_ID);
98
99 if !has_flow_context {
100 return Ok(None);
101 }
102
103 let incremental_mode = extensions
104 .get(FLOW_INCREMENTAL_MODE)
105 .map(|value| match value.as_str() {
106 v if v.eq_ignore_ascii_case(FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY) => {
107 Ok(FlowIncrementalMode::MemtableOnly)
108 }
109 _ => Err(invalid_query_context_extension(format!(
110 "Invalid value for {}: {}",
111 FLOW_INCREMENTAL_MODE, value
112 ))),
113 })
114 .transpose()?;
115
116 let incremental_after_seqs = extensions
117 .get(FLOW_INCREMENTAL_AFTER_SEQS)
118 .map(|value| parse_incremental_after_seqs(value.as_str()))
119 .transpose()?;
120
121 let return_region_seq = extensions
122 .get(FLOW_RETURN_REGION_SEQ)
123 .map(|value| parse_bool(FLOW_RETURN_REGION_SEQ, value.as_str()))
124 .transpose()?
125 .unwrap_or(false);
126
127 let sink_table_id = extensions
128 .get(FLOW_SINK_TABLE_ID)
129 .map(|value| {
130 value.parse::<TableId>().map_err(|_| {
131 invalid_query_context_extension(format!(
132 "Invalid value for {}: {}",
133 FLOW_SINK_TABLE_ID, value
134 ))
135 })
136 })
137 .transpose()?;
138
139 if matches!(incremental_mode, Some(FlowIncrementalMode::MemtableOnly)) {
140 let after_seqs = incremental_after_seqs.as_ref().ok_or_else(|| {
141 invalid_query_context_extension(format!(
142 "{} is required when {}={}.",
143 FLOW_INCREMENTAL_AFTER_SEQS,
144 FLOW_INCREMENTAL_MODE,
145 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY
146 ))
147 })?;
148 if after_seqs.is_empty() {
149 return Err(invalid_query_context_extension(format!(
150 "{} must not be empty when {}={}.",
151 FLOW_INCREMENTAL_AFTER_SEQS,
152 FLOW_INCREMENTAL_MODE,
153 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY
154 )));
155 }
156 }
157
158 Ok(Some(Self {
159 incremental_after_seqs,
160 incremental_mode,
161 return_region_seq,
162 sink_table_id,
163 }))
164 }
165
166 pub fn validate_for_scan(&self, source_region_id: RegionId) -> Result<bool> {
167 if self.sink_table_id.is_some() && self.sink_table_id == Some(source_region_id.table_id()) {
168 return Ok(false);
169 }
170
171 if matches!(
172 self.incremental_mode,
173 Some(FlowIncrementalMode::MemtableOnly)
174 ) {
175 let after_seqs = self.incremental_after_seqs.as_ref().ok_or_else(|| {
176 invalid_query_context_extension(format!(
177 "{} is required when {}=memtable_only.",
178 FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE
179 ))
180 })?;
181
182 if !after_seqs.contains_key(&source_region_id.as_u64()) {
183 return Err(invalid_query_context_extension(format!(
184 "Missing region {} in {} when {}=memtable_only.",
185 source_region_id, FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE
186 )));
187 }
188 }
189
190 Ok(self.incremental_after_seqs.is_some())
191 }
192
193 pub fn should_collect_region_watermark(&self) -> bool {
194 should_collect_region_watermark(
195 self.return_region_seq,
196 self.incremental_after_seqs.is_some(),
197 )
198 }
199}
200
201pub fn remote_dyn_filter_pushdown_enabled_from_extensions(
208 extensions: &HashMap<String, String>,
209) -> Result<bool> {
210 extensions
211 .get(QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN)
212 .map(|value| parse_bool(QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN, value.as_str()))
213 .transpose()
214 .map(|value| value.unwrap_or(true))
215}
216
217pub fn should_collect_region_watermark_from_extensions(
222 extensions: &HashMap<String, String>,
223) -> bool {
224 let return_region_seq = extensions
225 .get(FLOW_RETURN_REGION_SEQ)
226 .is_some_and(|value| value.eq_ignore_ascii_case("true"));
227 let has_incremental_after_seqs = extensions.contains_key(FLOW_INCREMENTAL_AFTER_SEQS);
228
229 should_collect_region_watermark(return_region_seq, has_incremental_after_seqs)
230}
231
232fn should_collect_region_watermark(
233 return_region_seq: bool,
234 has_incremental_after_seqs: bool,
235) -> bool {
236 return_region_seq || has_incremental_after_seqs
237}
238
239fn parse_incremental_after_seqs(value: &str) -> Result<HashMap<u64, u64>> {
240 let raw = serde_json::from_str::<HashMap<String, serde_json::Value>>(value).map_err(|e| {
241 invalid_query_context_extension(format!(
242 "Invalid JSON for {}: {} ({})",
243 FLOW_INCREMENTAL_AFTER_SEQS, value, e
244 ))
245 })?;
246
247 raw.into_iter()
248 .map(|(region_id, raw_seq)| {
249 let region_id = region_id.parse::<u64>().map_err(|_| {
250 invalid_query_context_extension(format!(
251 "Invalid region id in {}: {}",
252 FLOW_INCREMENTAL_AFTER_SEQS, region_id
253 ))
254 })?;
255
256 let seq = match raw_seq {
257 serde_json::Value::Number(num) => num.as_u64().ok_or_else(|| {
258 invalid_query_context_extension(format!(
259 "Invalid sequence value in {} for region {}: {}",
260 FLOW_INCREMENTAL_AFTER_SEQS, region_id, num
261 ))
262 })?,
263 serde_json::Value::String(s) => s.parse::<u64>().map_err(|_| {
264 invalid_query_context_extension(format!(
265 "Invalid sequence string in {} for region {}: {}",
266 FLOW_INCREMENTAL_AFTER_SEQS, region_id, s
267 ))
268 })?,
269 _ => {
270 return Err(invalid_query_context_extension(format!(
271 "Invalid sequence value type in {} for region {}",
272 FLOW_INCREMENTAL_AFTER_SEQS, region_id
273 )));
274 }
275 };
276
277 Ok((region_id, seq))
278 })
279 .collect()
280}
281
282fn parse_bool(option_name: &str, value: &str) -> Result<bool> {
283 match value {
284 v if v.eq_ignore_ascii_case("true") => Ok(true),
285 v if v.eq_ignore_ascii_case("false") => Ok(false),
286 _ => Err(invalid_query_context_extension(format!(
287 "Invalid value for {}: {}",
288 option_name, value
289 ))),
290 }
291}
292
293pub fn parse_scheduled_time_millis(extensions: &HashMap<String, String>) -> Result<Option<i64>> {
298 match extensions.get(FLOW_SCHEDULED_TIME_MILLIS) {
299 Some(val) => val.parse::<i64>().map(Some).map_err(|_| {
300 invalid_query_context_extension(format!(
301 "Invalid value for {}: {}",
302 FLOW_SCHEDULED_TIME_MILLIS, val
303 ))
304 }),
305 None => Ok(None),
306 }
307}
308
309pub fn parse_scheduled_time_datetime(
315 extensions: &HashMap<String, String>,
316) -> Result<Option<DateTime<Utc>>> {
317 match parse_scheduled_time_millis(extensions)? {
318 Some(millis) => DateTime::from_timestamp_millis(millis)
319 .map(Some)
320 .ok_or_else(|| {
321 invalid_query_context_extension(format!(
322 "Out-of-range timestamp for {}: {} ms",
323 FLOW_SCHEDULED_TIME_MILLIS, millis
324 ))
325 }),
326 None => Ok(None),
327 }
328}
329
330pub fn scheduled_time_from_ctx(query_ctx: &QueryContextRef) -> Option<DateTime<Utc>> {
335 let extensions = query_ctx.extensions();
336 parse_scheduled_time_datetime(&extensions).ok().flatten()
337}
338
339#[derive(Debug, Clone)]
343pub(crate) struct ScheduledTimeExtension {
344 pub(crate) scheduled_time: Option<DateTime<Utc>>,
345}
346
347impl ConfigExtension for ScheduledTimeExtension {
348 const PREFIX: &'static str = "flow_scheduled_time";
349}
350
351impl ExtensionOptions for ScheduledTimeExtension {
352 fn as_any(&self) -> &dyn std::any::Any {
353 self
354 }
355
356 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
357 self
358 }
359
360 fn cloned(&self) -> Box<dyn ExtensionOptions> {
361 Box::new(self.clone())
362 }
363
364 fn set(&mut self, key: &str, value: &str) -> datafusion::error::Result<()> {
365 Err(datafusion_common::DataFusionError::NotImplemented(format!(
366 "ScheduledTimeExtension does not support set key: {key} with value: {value}"
367 )))
368 }
369
370 fn entries(&self) -> Vec<ConfigEntry> {
371 vec![]
372 }
373}
374
375fn invalid_query_context_extension(reason: String) -> Error {
376 InvalidQueryContextExtensionSnafu { reason }.build()
377}
378
379#[cfg(test)]
380mod flow_extension_tests {
381 use super::*;
382
383 #[test]
384 fn test_parse_flow_extensions_returns_none_for_non_flow_query() {
385 let exts = HashMap::new();
386 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap();
387
388 assert_eq!(parsed, None);
389 }
390
391 #[test]
392 fn test_remote_dyn_filter_pushdown_enabled_from_extensions_defaults_true() {
393 assert!(remote_dyn_filter_pushdown_enabled_from_extensions(&HashMap::new()).unwrap());
394 }
395
396 #[test]
397 fn test_remote_dyn_filter_pushdown_enabled_from_extensions_parses_bool() {
398 let exts = HashMap::from([(
399 QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN.to_string(),
400 "false".to_string(),
401 )]);
402 assert!(!remote_dyn_filter_pushdown_enabled_from_extensions(&exts).unwrap());
403
404 let exts = HashMap::from([(
405 QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN.to_string(),
406 "true".to_string(),
407 )]);
408 assert!(remote_dyn_filter_pushdown_enabled_from_extensions(&exts).unwrap());
409 }
410
411 #[test]
412 fn test_remote_dyn_filter_pushdown_enabled_from_extensions_rejects_invalid_bool() {
413 let exts = HashMap::from([(
414 QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN.to_string(),
415 "invalid".to_string(),
416 )]);
417
418 let err = remote_dyn_filter_pushdown_enabled_from_extensions(&exts).unwrap_err();
419 assert!(format!("{err}").contains(QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN));
420 }
421
422 #[test]
423 fn test_parse_flow_extensions_memtable_only_success() {
424 let exts = HashMap::from([
425 (
426 FLOW_INCREMENTAL_MODE.to_string(),
427 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
428 ),
429 (
430 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
431 r#"{"1":10,"2":20}"#.to_string(),
432 ),
433 (FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string()),
434 (FLOW_SINK_TABLE_ID.to_string(), "1024".to_string()),
435 ]);
436
437 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
438 .unwrap()
439 .unwrap();
440 assert_eq!(
441 parsed.incremental_mode,
442 Some(FlowIncrementalMode::MemtableOnly)
443 );
444 assert_eq!(
445 parsed.incremental_after_seqs.unwrap(),
446 HashMap::from([(1, 10), (2, 20)])
447 );
448 assert!(parsed.return_region_seq);
449 assert_eq!(parsed.sink_table_id, Some(1024));
450 }
451
452 #[test]
453 fn test_parse_flow_extensions_mode_requires_after_seqs() {
454 let exts = HashMap::from([(
455 FLOW_INCREMENTAL_MODE.to_string(),
456 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
457 )]);
458
459 let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
460 assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
461 }
462
463 #[test]
464 fn test_parse_flow_extensions_invalid_mode() {
465 let exts = HashMap::from([(FLOW_INCREMENTAL_MODE.to_string(), "foo".to_string())]);
466
467 let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
468 assert!(format!("{err}").contains(FLOW_INCREMENTAL_MODE));
469 }
470
471 #[test]
472 fn test_parse_flow_extensions_invalid_after_seqs_json() {
473 let exts = HashMap::from([
474 (
475 FLOW_INCREMENTAL_MODE.to_string(),
476 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
477 ),
478 (
479 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
480 "not-json".to_string(),
481 ),
482 ]);
483
484 let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
485 assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
486 }
487
488 #[test]
489 fn test_parse_flow_extensions_after_seqs_string_values() {
490 let exts = HashMap::from([(
491 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
492 r#"{"1":"10","2":"20"}"#.to_string(),
493 )]);
494
495 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
496 .unwrap()
497 .unwrap();
498 assert_eq!(
499 parsed.incremental_after_seqs.unwrap(),
500 HashMap::from([(1, 10), (2, 20)])
501 );
502 }
503
504 #[test]
505 fn test_parse_flow_extensions_after_seqs_invalid_value_type() {
506 let exts = HashMap::from([(
507 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
508 r#"{"1":true}"#.to_string(),
509 )]);
510
511 let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
512 assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
513 }
514
515 #[test]
516 fn test_parse_flow_extensions_invalid_sink_table_id() {
517 let exts = HashMap::from([(FLOW_SINK_TABLE_ID.to_string(), "x".to_string())]);
518
519 let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
520 assert!(format!("{err}").contains(FLOW_SINK_TABLE_ID));
521 }
522
523 #[test]
524 fn test_validate_for_scan_missing_source_region() {
525 let source_region_id = RegionId::new(100, 2);
526 let existing_region_id = RegionId::new(100, 1);
527 let exts = HashMap::from([
528 (
529 FLOW_INCREMENTAL_MODE.to_string(),
530 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
531 ),
532 (
533 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
534 format!(r#"{{"{}":10}}"#, existing_region_id.as_u64()),
535 ),
536 ]);
537
538 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
539 .unwrap()
540 .unwrap();
541 let err = parsed.validate_for_scan(source_region_id).unwrap_err();
542 assert!(format!("{err}").contains("Missing region"));
543 }
544
545 #[test]
546 fn test_validate_for_scan_sink_table_excluded() {
547 let source_region_id = RegionId::new(1024, 1);
548 let exts = HashMap::from([
549 (
550 FLOW_INCREMENTAL_MODE.to_string(),
551 FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
552 ),
553 (
554 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
555 format!(r#"{{"{}":10}}"#, source_region_id.as_u64()),
556 ),
557 (FLOW_SINK_TABLE_ID.to_string(), "1024".to_string()),
558 ]);
559
560 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
561 .unwrap()
562 .unwrap();
563 let apply_incremental = parsed.validate_for_scan(source_region_id).unwrap();
564 assert!(!apply_incremental);
565 }
566
567 #[test]
568 fn test_should_collect_region_watermark_defaults_false() {
569 let parsed = FlowQueryExtensions::default();
570 assert!(!parsed.should_collect_region_watermark());
571 }
572
573 #[test]
574 fn test_should_collect_region_watermark_true_for_return_region_seq() {
575 let parsed = FlowQueryExtensions {
576 return_region_seq: true,
577 ..Default::default()
578 };
579 assert!(parsed.should_collect_region_watermark());
580 }
581
582 #[test]
583 fn test_should_collect_region_watermark_true_for_incremental_query() {
584 let parsed = FlowQueryExtensions {
585 incremental_after_seqs: Some(HashMap::from([(1, 10)])),
586 ..Default::default()
587 };
588 assert!(parsed.should_collect_region_watermark());
589 }
590
591 #[test]
592 fn test_should_collect_region_watermark_from_extensions() {
593 let exts = HashMap::from([(FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string())]);
594 assert!(should_collect_region_watermark_from_extensions(&exts));
595
596 let exts = HashMap::from([(
597 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
598 r#"{"1":10}"#.to_string(),
599 )]);
600 assert!(should_collect_region_watermark_from_extensions(&exts));
601
602 let exts = HashMap::from([(FLOW_RETURN_REGION_SEQ.to_string(), "false".to_string())]);
603 assert!(!should_collect_region_watermark_from_extensions(&exts));
604 assert!(!should_collect_region_watermark_from_extensions(
605 &HashMap::new()
606 ));
607 }
608
609 #[test]
610 fn test_parse_flow_extensions_return_region_seq_only_returns_some() {
611 let exts = HashMap::from([(FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string())]);
612
613 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
614 .unwrap()
615 .unwrap();
616
617 assert!(parsed.return_region_seq);
618 }
619
620 #[test]
621 fn test_parse_flow_extensions_sink_table_only_returns_some() {
622 let exts = HashMap::from([(FLOW_SINK_TABLE_ID.to_string(), "1024".to_string())]);
623
624 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
625 .unwrap()
626 .unwrap();
627
628 assert_eq!(parsed.sink_table_id, Some(1024));
629 }
630
631 #[test]
632 fn test_parse_flow_extensions_incremental_after_seqs_only_returns_some() {
633 let exts = HashMap::from([(
634 FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
635 r#"{"1":10}"#.to_string(),
636 )]);
637
638 let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
639 .unwrap()
640 .unwrap();
641
642 assert_eq!(
643 parsed.incremental_after_seqs,
644 Some(HashMap::from([(1, 10)]))
645 );
646 }
647
648 #[test]
651 fn test_parse_scheduled_time_millis_absent() {
652 let exts = HashMap::new();
653 assert_eq!(parse_scheduled_time_millis(&exts).unwrap(), None);
654 }
655
656 #[test]
657 fn test_parse_scheduled_time_millis_valid() {
658 let exts = HashMap::from([(
659 FLOW_SCHEDULED_TIME_MILLIS.to_string(),
660 "1700000000000".to_string(),
661 )]);
662 assert_eq!(
663 parse_scheduled_time_millis(&exts).unwrap(),
664 Some(1700000000000)
665 );
666 }
667
668 #[test]
669 fn test_parse_scheduled_time_millis_malformed() {
670 let exts = HashMap::from([(
671 FLOW_SCHEDULED_TIME_MILLIS.to_string(),
672 "not-a-number".to_string(),
673 )]);
674 let err = parse_scheduled_time_millis(&exts).unwrap_err();
675 assert!(format!("{err}").contains(FLOW_SCHEDULED_TIME_MILLIS));
676 }
677
678 #[test]
679 fn test_parse_scheduled_time_datetime_valid() {
680 let exts = HashMap::from([(
681 FLOW_SCHEDULED_TIME_MILLIS.to_string(),
682 "1700000000000".to_string(),
683 )]);
684 let dt = parse_scheduled_time_datetime(&exts).unwrap().unwrap();
685 assert_eq!(dt.timestamp_millis(), 1700000000000);
686 }
687
688 #[test]
689 fn test_parse_scheduled_time_datetime_negative_millis() {
690 let exts = HashMap::from([(FLOW_SCHEDULED_TIME_MILLIS.to_string(), "-1".to_string())]);
691 let dt = parse_scheduled_time_datetime(&exts).unwrap().unwrap();
692 assert_eq!(dt.timestamp_millis(), -1);
693 }
694
695 #[test]
696 fn test_parse_scheduled_time_datetime_out_of_range() {
697 let exts = HashMap::from([(FLOW_SCHEDULED_TIME_MILLIS.to_string(), i64::MAX.to_string())]);
699 let err = parse_scheduled_time_datetime(&exts).unwrap_err();
700 assert!(format!("{err}").contains("Out-of-range"));
701 }
702}