Skip to main content

query/
options.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18use chrono::{DateTime, Utc};
19use common_base::memory_limit::MemoryLimit;
20use common_base::readable_size::ReadableSize;
21use datafusion::config::{ConfigEntry, ConfigExtension, ExtensionOptions};
22use serde::{Deserialize, Serialize};
23use session::context::QueryContextRef;
24use store_api::storage::RegionId;
25use table::metadata::TableId;
26
27use crate::error::{Error, InvalidQueryContextExtensionSnafu, Result};
28
29pub const FLOW_INCREMENTAL_AFTER_SEQS: &str = "flow.incremental_after_seqs";
30pub const FLOW_INCREMENTAL_MODE: &str = "flow.incremental_mode";
31pub const FLOW_RETURN_REGION_SEQ: &str = "flow.return_region_seq";
32pub const FLOW_SINK_TABLE_ID: &str = "flow.sink_table_id";
33/// Flow scheduler binding for the logical time of one scheduled attempt.
34/// Query planning, SQL/TQL parsing, range-select rewrite and DataFusion
35/// execution read this extension so `now()` is stable for the whole attempt.
36pub const FLOW_SCHEDULED_TIME_MILLIS: &str = "flow.scheduled_time_millis";
37/// Enable by default, set to false to explicitly disable.
38pub const QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN: &str =
39    "query.enable_remote_dynamic_filter_pushdown";
40
41pub const FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY: &str = "memtable_only";
42pub const FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE: &str = "sequence_range";
43
44/// Query spill mode controlling disk manager behavior.
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum QuerySpillMode {
48    /// Preserve DataFusion default disk manager behavior (OS temp directory).
49    Default,
50    /// Explicitly configure spill path, quota, and compression.
51    Custom,
52    /// Explicitly disable disk spilling; temporary file creation will error.
53    Disabled,
54}
55
56/// Compression for spilled data files.
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "snake_case")]
59pub enum QuerySpillCompression {
60    /// No compression (default, matches DataFusion default).
61    Uncompressed,
62    /// LZ4 frame compression.
63    Lz4Frame,
64    /// Zstandard compression.
65    Zstd,
66}
67
68/// Memory pool allocation policy.
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum QueryMemoryPoolPolicy {
72    /// Greedy first-come-first-served (default).
73    Greedy,
74    /// Fair divides memory available after unspillable reservations evenly among
75    /// spillable reservations and may trigger earlier spills.
76    Fair,
77}
78
79/// Query engine config
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(default)]
82pub struct QueryOptions {
83    /// Parallelism of query engine. Default to 0, which implies the number of logical CPUs.
84    pub parallelism: usize,
85    /// Whether to allow query fallback when push down fails.
86    pub allow_query_fallback: bool,
87    /// Memory pool size for query execution. Setting it to 0 disables the limit (unbounded).
88    /// Supports absolute size (e.g., "2GB") or percentage (e.g., "50%").
89    /// When this limit is reached, queries will fail with ResourceExhausted error.
90    pub memory_pool_size: MemoryLimit,
91    /// Whether to expose per-region query load metrics.
92    #[serde(skip)]
93    pub enable_per_region_metrics: bool,
94    /// Experimental: spill-to-disk mode.
95    /// - `default`: preserve DataFusion built-in OS temp directory behavior.
96    /// - `custom`: explicitly configure spill path, max directory size, and compression.
97    /// - `disabled`: explicitly disable disk spilling.
98    pub experimental_spill_mode: QuerySpillMode,
99    /// Experimental: spill directory path. Ignored unless `experimental_spill_mode` is
100    /// `"custom"`. When set, spill files are written into this directory.
101    pub experimental_spill_path: Option<PathBuf>,
102    /// Experimental: maximum total size of the spill directory (data written to spill files).
103    /// Ignored unless `experimental_spill_mode` is `"custom"`. Default: `1GiB`.
104    pub experimental_spill_max_temp_directory_size: ReadableSize,
105    /// Experimental: compression algorithm applied to spilled data.
106    /// Ignored unless `experimental_spill_mode` is `"custom"`. Default: `uncompressed`.
107    pub experimental_spill_compression: QuerySpillCompression,
108    /// Experimental: memory pool allocation policy.
109    /// - `greedy`: Greedy first-come-first-served (default).
110    /// - `fair`: Fair divides memory available after unspillable reservations
111    ///   evenly among spillable reservations and may trigger earlier spills.
112    pub experimental_memory_pool_policy: QueryMemoryPoolPolicy,
113}
114
115#[allow(clippy::derivable_impls)]
116impl Default for QueryOptions {
117    fn default() -> Self {
118        Self {
119            parallelism: 0,
120            allow_query_fallback: false,
121            memory_pool_size: MemoryLimit::default(),
122            enable_per_region_metrics: false,
123            experimental_spill_mode: QuerySpillMode::Default,
124            experimental_spill_path: None,
125            experimental_spill_max_temp_directory_size: ReadableSize::gb(1),
126            experimental_spill_compression: QuerySpillCompression::Uncompressed,
127            experimental_memory_pool_policy: QueryMemoryPoolPolicy::Greedy,
128        }
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum FlowIncrementalMode {
134    MemtableOnly,
135    SequenceRange,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Default)]
139pub struct FlowQueryExtensions {
140    /// Maps region id -> lower exclusive sequence bound for incremental reads.
141    pub incremental_after_seqs: Option<HashMap<u64, u64>>,
142    /// Incremental read mode requested by the caller.
143    pub incremental_mode: Option<FlowIncrementalMode>,
144    /// Whether the caller expects per-region watermark metadata in terminal metrics.
145    pub return_region_seq: bool,
146    /// Optional sink table id used to distinguish source scans from sink reads.
147    pub sink_table_id: Option<TableId>,
148}
149
150impl FlowQueryExtensions {
151    /// Parses flow-specific query extensions when any flow key is present.
152    ///
153    /// Returns `Ok(None)` for ordinary queries with no flow-related extensions,
154    /// `Ok(Some(_))` when flow context is present and valid, and `Err(_)` when a
155    /// flow-related extension is present but malformed or incomplete.
156    pub fn parse_flow_extensions(extensions: &HashMap<String, String>) -> Result<Option<Self>> {
157        let has_flow_context = extensions.contains_key(FLOW_INCREMENTAL_AFTER_SEQS)
158            || extensions.contains_key(FLOW_INCREMENTAL_MODE)
159            || extensions.contains_key(FLOW_RETURN_REGION_SEQ)
160            || extensions.contains_key(FLOW_SINK_TABLE_ID);
161
162        if !has_flow_context {
163            return Ok(None);
164        }
165
166        let incremental_mode = extensions
167            .get(FLOW_INCREMENTAL_MODE)
168            .map(|value| match value.as_str() {
169                v if v.eq_ignore_ascii_case(FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY) => {
170                    Ok(FlowIncrementalMode::MemtableOnly)
171                }
172                v if v.eq_ignore_ascii_case(FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE) => {
173                    Ok(FlowIncrementalMode::SequenceRange)
174                }
175                _ => Err(invalid_query_context_extension(format!(
176                    "Invalid value for {}: {}",
177                    FLOW_INCREMENTAL_MODE, value
178                ))),
179            })
180            .transpose()?;
181
182        let incremental_after_seqs = extensions
183            .get(FLOW_INCREMENTAL_AFTER_SEQS)
184            .map(|value| parse_incremental_after_seqs(value.as_str()))
185            .transpose()?;
186
187        let return_region_seq = extensions
188            .get(FLOW_RETURN_REGION_SEQ)
189            .map(|value| parse_bool(FLOW_RETURN_REGION_SEQ, value.as_str()))
190            .transpose()?
191            .unwrap_or(false);
192
193        let sink_table_id = extensions
194            .get(FLOW_SINK_TABLE_ID)
195            .map(|value| {
196                value.parse::<TableId>().map_err(|_| {
197                    invalid_query_context_extension(format!(
198                        "Invalid value for {}: {}",
199                        FLOW_SINK_TABLE_ID, value
200                    ))
201                })
202            })
203            .transpose()?;
204
205        if matches!(
206            incremental_mode,
207            Some(FlowIncrementalMode::MemtableOnly | FlowIncrementalMode::SequenceRange)
208        ) {
209            let mode = if incremental_mode == Some(FlowIncrementalMode::MemtableOnly) {
210                FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY
211            } else {
212                FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE
213            };
214            let after_seqs = incremental_after_seqs.as_ref().ok_or_else(|| {
215                invalid_query_context_extension(format!(
216                    "{} is required when {}={}.",
217                    FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, mode
218                ))
219            })?;
220            if after_seqs.is_empty() {
221                return Err(invalid_query_context_extension(format!(
222                    "{} must not be empty when {}={}.",
223                    FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, mode
224                )));
225            }
226        }
227
228        Ok(Some(Self {
229            incremental_after_seqs,
230            incremental_mode,
231            return_region_seq,
232            sink_table_id,
233        }))
234    }
235
236    pub fn validate_for_scan(&self, source_region_id: RegionId) -> Result<bool> {
237        if self.sink_table_id.is_some() && self.sink_table_id == Some(source_region_id.table_id()) {
238            return Ok(false);
239        }
240
241        if matches!(
242            self.incremental_mode,
243            Some(FlowIncrementalMode::MemtableOnly | FlowIncrementalMode::SequenceRange)
244        ) {
245            let mode = if self.incremental_mode == Some(FlowIncrementalMode::MemtableOnly) {
246                FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY
247            } else {
248                FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE
249            };
250            let after_seqs = self.incremental_after_seqs.as_ref().ok_or_else(|| {
251                invalid_query_context_extension(format!(
252                    "{} is required when {}={}.",
253                    FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, mode
254                ))
255            })?;
256
257            if !after_seqs.contains_key(&source_region_id.as_u64()) {
258                return Err(invalid_query_context_extension(format!(
259                    "Missing region {} in {} when {}={}.",
260                    source_region_id, FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, mode
261                )));
262            }
263        }
264
265        Ok(self.incremental_after_seqs.is_some())
266    }
267
268    pub fn should_collect_region_watermark(&self) -> bool {
269        should_collect_region_watermark(
270            self.return_region_seq,
271            self.incremental_after_seqs.is_some(),
272        )
273    }
274}
275
276/// Returns whether query-level remote dynamic filter propagation is enabled.
277///
278/// The option defaults to enabled to preserve existing behavior. Callers may set
279/// `query.enable_remote_dynamic_filter_pushdown=false` in query context
280/// extensions to disable FE->DN remote dynamic filter propagation for a single
281/// query.
282pub fn remote_dyn_filter_pushdown_enabled_from_extensions(
283    extensions: &HashMap<String, String>,
284) -> Result<bool> {
285    extensions
286        .get(QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN)
287        .map(|value| parse_bool(QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN, value.as_str()))
288        .transpose()
289        .map(|value| value.unwrap_or(true))
290}
291
292/// Returns whether raw Flow query extensions request terminal region watermark collection.
293///
294/// This is only an intent/presence check for transport/scan plumbing; callers that need
295/// validated Flow options must still use [`FlowQueryExtensions::parse_flow_extensions`].
296pub fn should_collect_region_watermark_from_extensions(
297    extensions: &HashMap<String, String>,
298) -> bool {
299    let return_region_seq = extensions
300        .get(FLOW_RETURN_REGION_SEQ)
301        .is_some_and(|value| value.eq_ignore_ascii_case("true"));
302    let has_incremental_after_seqs = extensions.contains_key(FLOW_INCREMENTAL_AFTER_SEQS);
303
304    should_collect_region_watermark(return_region_seq, has_incremental_after_seqs)
305}
306
307fn should_collect_region_watermark(
308    return_region_seq: bool,
309    has_incremental_after_seqs: bool,
310) -> bool {
311    return_region_seq || has_incremental_after_seqs
312}
313
314fn parse_incremental_after_seqs(value: &str) -> Result<HashMap<u64, u64>> {
315    let raw = serde_json::from_str::<HashMap<String, serde_json::Value>>(value).map_err(|e| {
316        invalid_query_context_extension(format!(
317            "Invalid JSON for {}: {} ({})",
318            FLOW_INCREMENTAL_AFTER_SEQS, value, e
319        ))
320    })?;
321
322    raw.into_iter()
323        .map(|(region_id, raw_seq)| {
324            let region_id = region_id.parse::<u64>().map_err(|_| {
325                invalid_query_context_extension(format!(
326                    "Invalid region id in {}: {}",
327                    FLOW_INCREMENTAL_AFTER_SEQS, region_id
328                ))
329            })?;
330
331            let seq = match raw_seq {
332                serde_json::Value::Number(num) => num.as_u64().ok_or_else(|| {
333                    invalid_query_context_extension(format!(
334                        "Invalid sequence value in {} for region {}: {}",
335                        FLOW_INCREMENTAL_AFTER_SEQS, region_id, num
336                    ))
337                })?,
338                serde_json::Value::String(s) => s.parse::<u64>().map_err(|_| {
339                    invalid_query_context_extension(format!(
340                        "Invalid sequence string in {} for region {}: {}",
341                        FLOW_INCREMENTAL_AFTER_SEQS, region_id, s
342                    ))
343                })?,
344                _ => {
345                    return Err(invalid_query_context_extension(format!(
346                        "Invalid sequence value type in {} for region {}",
347                        FLOW_INCREMENTAL_AFTER_SEQS, region_id
348                    )));
349                }
350            };
351
352            Ok((region_id, seq))
353        })
354        .collect()
355}
356
357fn parse_bool(option_name: &str, value: &str) -> Result<bool> {
358    match value {
359        v if v.eq_ignore_ascii_case("true") => Ok(true),
360        v if v.eq_ignore_ascii_case("false") => Ok(false),
361        _ => Err(invalid_query_context_extension(format!(
362            "Invalid value for {}: {}",
363            option_name, value
364        ))),
365    }
366}
367
368/// Parse the scheduled time (in milliseconds since Unix epoch) from extensions.
369///
370/// Returns `Ok(None)` if the extension key is absent, `Ok(Some(millis))` on success,
371/// or `Err` if the value is malformed.
372pub fn parse_scheduled_time_millis(extensions: &HashMap<String, String>) -> Result<Option<i64>> {
373    match extensions.get(FLOW_SCHEDULED_TIME_MILLIS) {
374        Some(val) => val.parse::<i64>().map(Some).map_err(|_| {
375            invalid_query_context_extension(format!(
376                "Invalid value for {}: {}",
377                FLOW_SCHEDULED_TIME_MILLIS, val
378            ))
379        }),
380        None => Ok(None),
381    }
382}
383
384/// Parse the scheduled time from extensions into a [`DateTime<Utc>`].
385///
386/// Returns `Ok(None)` if the extension key is absent, `Ok(Some(datetime))` on success,
387/// or `Err` if the value is malformed.  Millis that produce an out-of-range timestamp are
388/// also rejected.
389pub fn parse_scheduled_time_datetime(
390    extensions: &HashMap<String, String>,
391) -> Result<Option<DateTime<Utc>>> {
392    match parse_scheduled_time_millis(extensions)? {
393        Some(millis) => DateTime::from_timestamp_millis(millis)
394            .map(Some)
395            .ok_or_else(|| {
396                invalid_query_context_extension(format!(
397                    "Out-of-range timestamp for {}: {} ms",
398                    FLOW_SCHEDULED_TIME_MILLIS, millis
399                ))
400            }),
401        None => Ok(None),
402    }
403}
404
405/// Best-effort helper: extract scheduled time from a [`QueryContextRef`] as a [`DateTime<Utc>`].
406///
407/// Errors are silently swallowed, returning `None`; use [`parse_scheduled_time_datetime`]
408/// at call sites that need to reject malformed scheduled time.
409pub fn scheduled_time_from_ctx(query_ctx: &QueryContextRef) -> Option<DateTime<Utc>> {
410    let extensions = query_ctx.extensions();
411    parse_scheduled_time_datetime(&extensions).ok().flatten()
412}
413
414/// Carries the scheduled logical "now" through [`ConfigOptions::extensions`] so
415/// that the distributed plan analyzer can apply it during expression
416/// simplification (preventing wall-clock constant-folding of `now()`).
417#[derive(Debug, Clone)]
418pub(crate) struct ScheduledTimeExtension {
419    pub(crate) scheduled_time: Option<DateTime<Utc>>,
420}
421
422impl ConfigExtension for ScheduledTimeExtension {
423    const PREFIX: &'static str = "flow_scheduled_time";
424}
425
426impl ExtensionOptions for ScheduledTimeExtension {
427    fn as_any(&self) -> &dyn std::any::Any {
428        self
429    }
430
431    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
432        self
433    }
434
435    fn cloned(&self) -> Box<dyn ExtensionOptions> {
436        Box::new(self.clone())
437    }
438
439    fn set(&mut self, key: &str, value: &str) -> datafusion::error::Result<()> {
440        Err(datafusion_common::DataFusionError::NotImplemented(format!(
441            "ScheduledTimeExtension does not support set key: {key} with value: {value}"
442        )))
443    }
444
445    fn entries(&self) -> Vec<ConfigEntry> {
446        vec![]
447    }
448}
449
450fn invalid_query_context_extension(reason: String) -> Error {
451    InvalidQueryContextExtensionSnafu { reason }.build()
452}
453
454#[cfg(test)]
455mod flow_extension_tests {
456    use super::*;
457
458    #[test]
459    fn test_parse_flow_extensions_returns_none_for_non_flow_query() {
460        let exts = HashMap::new();
461        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap();
462
463        assert_eq!(parsed, None);
464    }
465
466    #[test]
467    fn test_remote_dyn_filter_pushdown_enabled_from_extensions_defaults_true() {
468        assert!(remote_dyn_filter_pushdown_enabled_from_extensions(&HashMap::new()).unwrap());
469    }
470
471    #[test]
472    fn test_remote_dyn_filter_pushdown_enabled_from_extensions_parses_bool() {
473        let exts = HashMap::from([(
474            QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN.to_string(),
475            "false".to_string(),
476        )]);
477        assert!(!remote_dyn_filter_pushdown_enabled_from_extensions(&exts).unwrap());
478
479        let exts = HashMap::from([(
480            QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN.to_string(),
481            "true".to_string(),
482        )]);
483        assert!(remote_dyn_filter_pushdown_enabled_from_extensions(&exts).unwrap());
484    }
485
486    #[test]
487    fn test_remote_dyn_filter_pushdown_enabled_from_extensions_rejects_invalid_bool() {
488        let exts = HashMap::from([(
489            QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN.to_string(),
490            "invalid".to_string(),
491        )]);
492
493        let err = remote_dyn_filter_pushdown_enabled_from_extensions(&exts).unwrap_err();
494        assert!(format!("{err}").contains(QUERY_ENABLE_REMOTE_DYNAMIC_FILTER_PUSHDOWN));
495    }
496
497    #[test]
498    fn test_parse_flow_extensions_memtable_only_success() {
499        let exts = HashMap::from([
500            (
501                FLOW_INCREMENTAL_MODE.to_string(),
502                FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
503            ),
504            (
505                FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
506                r#"{"1":10,"2":20}"#.to_string(),
507            ),
508            (FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string()),
509            (FLOW_SINK_TABLE_ID.to_string(), "1024".to_string()),
510        ]);
511
512        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
513            .unwrap()
514            .unwrap();
515        assert_eq!(
516            parsed.incremental_mode,
517            Some(FlowIncrementalMode::MemtableOnly)
518        );
519        assert_eq!(
520            parsed.incremental_after_seqs.unwrap(),
521            HashMap::from([(1, 10), (2, 20)])
522        );
523        assert!(parsed.return_region_seq);
524        assert_eq!(parsed.sink_table_id, Some(1024));
525    }
526
527    #[test]
528    fn test_parse_flow_extensions_sequence_range_success() {
529        let exts = HashMap::from([
530            (
531                FLOW_INCREMENTAL_MODE.to_string(),
532                FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE.to_string(),
533            ),
534            (
535                FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
536                r#"{"1":10}"#.to_string(),
537            ),
538        ]);
539
540        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
541            .unwrap()
542            .unwrap();
543        assert_eq!(
544            parsed.incremental_mode,
545            Some(FlowIncrementalMode::SequenceRange)
546        );
547        assert_eq!(
548            parsed.incremental_after_seqs,
549            Some(HashMap::from([(1, 10)]))
550        );
551    }
552
553    #[test]
554    fn test_parse_flow_extensions_sequence_range_rejects_empty_after_seqs() {
555        let exts = HashMap::from([
556            (
557                FLOW_INCREMENTAL_MODE.to_string(),
558                FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE.to_string(),
559            ),
560            (FLOW_INCREMENTAL_AFTER_SEQS.to_string(), "{}".to_string()),
561        ]);
562
563        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
564        assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
565    }
566
567    #[test]
568    fn test_parse_flow_extensions_sequence_range_requires_after_seqs() {
569        let exts = HashMap::from([(
570            FLOW_INCREMENTAL_MODE.to_string(),
571            FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE.to_string(),
572        )]);
573
574        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
575        assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
576    }
577
578    #[test]
579    fn test_parse_flow_extensions_mode_requires_after_seqs() {
580        let exts = HashMap::from([(
581            FLOW_INCREMENTAL_MODE.to_string(),
582            FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
583        )]);
584
585        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
586        assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
587    }
588
589    #[test]
590    fn test_parse_flow_extensions_invalid_mode() {
591        let exts = HashMap::from([(FLOW_INCREMENTAL_MODE.to_string(), "foo".to_string())]);
592
593        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
594        assert!(format!("{err}").contains(FLOW_INCREMENTAL_MODE));
595    }
596
597    #[test]
598    fn test_parse_flow_extensions_invalid_after_seqs_json() {
599        let exts = HashMap::from([
600            (
601                FLOW_INCREMENTAL_MODE.to_string(),
602                FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
603            ),
604            (
605                FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
606                "not-json".to_string(),
607            ),
608        ]);
609
610        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
611        assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
612    }
613
614    #[test]
615    fn test_parse_flow_extensions_after_seqs_string_values() {
616        let exts = HashMap::from([(
617            FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
618            r#"{"1":"10","2":"20"}"#.to_string(),
619        )]);
620
621        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
622            .unwrap()
623            .unwrap();
624        assert_eq!(
625            parsed.incremental_after_seqs.unwrap(),
626            HashMap::from([(1, 10), (2, 20)])
627        );
628    }
629
630    #[test]
631    fn test_parse_flow_extensions_after_seqs_invalid_value_type() {
632        let exts = HashMap::from([(
633            FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
634            r#"{"1":true}"#.to_string(),
635        )]);
636
637        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
638        assert!(format!("{err}").contains(FLOW_INCREMENTAL_AFTER_SEQS));
639    }
640
641    #[test]
642    fn test_parse_flow_extensions_invalid_sink_table_id() {
643        let exts = HashMap::from([(FLOW_SINK_TABLE_ID.to_string(), "x".to_string())]);
644
645        let err = FlowQueryExtensions::parse_flow_extensions(&exts).unwrap_err();
646        assert!(format!("{err}").contains(FLOW_SINK_TABLE_ID));
647    }
648
649    #[test]
650    fn test_validate_for_scan_missing_source_region() {
651        let source_region_id = RegionId::new(100, 2);
652        let existing_region_id = RegionId::new(100, 1);
653        let exts = HashMap::from([
654            (
655                FLOW_INCREMENTAL_MODE.to_string(),
656                FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
657            ),
658            (
659                FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
660                format!(r#"{{"{}":10}}"#, existing_region_id.as_u64()),
661            ),
662        ]);
663
664        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
665            .unwrap()
666            .unwrap();
667        let err = parsed.validate_for_scan(source_region_id).unwrap_err();
668        assert!(format!("{err}").contains("Missing region"));
669    }
670
671    #[test]
672    fn test_validate_for_scan_sink_table_excluded() {
673        let source_region_id = RegionId::new(1024, 1);
674        let exts = HashMap::from([
675            (
676                FLOW_INCREMENTAL_MODE.to_string(),
677                FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(),
678            ),
679            (
680                FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
681                format!(r#"{{"{}":10}}"#, source_region_id.as_u64()),
682            ),
683            (FLOW_SINK_TABLE_ID.to_string(), "1024".to_string()),
684        ]);
685
686        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
687            .unwrap()
688            .unwrap();
689        let apply_incremental = parsed.validate_for_scan(source_region_id).unwrap();
690        assert!(!apply_incremental);
691    }
692
693    #[test]
694    fn test_should_collect_region_watermark_defaults_false() {
695        let parsed = FlowQueryExtensions::default();
696        assert!(!parsed.should_collect_region_watermark());
697    }
698
699    #[test]
700    fn test_should_collect_region_watermark_true_for_return_region_seq() {
701        let parsed = FlowQueryExtensions {
702            return_region_seq: true,
703            ..Default::default()
704        };
705        assert!(parsed.should_collect_region_watermark());
706    }
707
708    #[test]
709    fn test_should_collect_region_watermark_true_for_incremental_query() {
710        let parsed = FlowQueryExtensions {
711            incremental_after_seqs: Some(HashMap::from([(1, 10)])),
712            ..Default::default()
713        };
714        assert!(parsed.should_collect_region_watermark());
715    }
716
717    #[test]
718    fn test_should_collect_region_watermark_from_extensions() {
719        let exts = HashMap::from([(FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string())]);
720        assert!(should_collect_region_watermark_from_extensions(&exts));
721
722        let exts = HashMap::from([(
723            FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
724            r#"{"1":10}"#.to_string(),
725        )]);
726        assert!(should_collect_region_watermark_from_extensions(&exts));
727
728        let exts = HashMap::from([(FLOW_RETURN_REGION_SEQ.to_string(), "false".to_string())]);
729        assert!(!should_collect_region_watermark_from_extensions(&exts));
730        assert!(!should_collect_region_watermark_from_extensions(
731            &HashMap::new()
732        ));
733    }
734
735    #[test]
736    fn test_parse_flow_extensions_return_region_seq_only_returns_some() {
737        let exts = HashMap::from([(FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string())]);
738
739        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
740            .unwrap()
741            .unwrap();
742
743        assert!(parsed.return_region_seq);
744    }
745
746    #[test]
747    fn test_parse_flow_extensions_sink_table_only_returns_some() {
748        let exts = HashMap::from([(FLOW_SINK_TABLE_ID.to_string(), "1024".to_string())]);
749
750        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
751            .unwrap()
752            .unwrap();
753
754        assert_eq!(parsed.sink_table_id, Some(1024));
755    }
756
757    #[test]
758    fn test_parse_flow_extensions_incremental_after_seqs_only_returns_some() {
759        let exts = HashMap::from([(
760            FLOW_INCREMENTAL_AFTER_SEQS.to_string(),
761            r#"{"1":10}"#.to_string(),
762        )]);
763
764        let parsed = FlowQueryExtensions::parse_flow_extensions(&exts)
765            .unwrap()
766            .unwrap();
767
768        assert_eq!(
769            parsed.incremental_after_seqs,
770            Some(HashMap::from([(1, 10)]))
771        );
772    }
773
774    // --- scheduled time helper tests ---
775
776    #[test]
777    fn test_parse_scheduled_time_millis_absent() {
778        let exts = HashMap::new();
779        assert_eq!(parse_scheduled_time_millis(&exts).unwrap(), None);
780    }
781
782    #[test]
783    fn test_parse_scheduled_time_millis_valid() {
784        let exts = HashMap::from([(
785            FLOW_SCHEDULED_TIME_MILLIS.to_string(),
786            "1700000000000".to_string(),
787        )]);
788        assert_eq!(
789            parse_scheduled_time_millis(&exts).unwrap(),
790            Some(1700000000000)
791        );
792    }
793
794    #[test]
795    fn test_parse_scheduled_time_millis_malformed() {
796        let exts = HashMap::from([(
797            FLOW_SCHEDULED_TIME_MILLIS.to_string(),
798            "not-a-number".to_string(),
799        )]);
800        let err = parse_scheduled_time_millis(&exts).unwrap_err();
801        assert!(format!("{err}").contains(FLOW_SCHEDULED_TIME_MILLIS));
802    }
803
804    #[test]
805    fn test_parse_scheduled_time_datetime_valid() {
806        let exts = HashMap::from([(
807            FLOW_SCHEDULED_TIME_MILLIS.to_string(),
808            "1700000000000".to_string(),
809        )]);
810        let dt = parse_scheduled_time_datetime(&exts).unwrap().unwrap();
811        assert_eq!(dt.timestamp_millis(), 1700000000000);
812    }
813
814    #[test]
815    fn test_parse_scheduled_time_datetime_negative_millis() {
816        let exts = HashMap::from([(FLOW_SCHEDULED_TIME_MILLIS.to_string(), "-1".to_string())]);
817        let dt = parse_scheduled_time_datetime(&exts).unwrap().unwrap();
818        assert_eq!(dt.timestamp_millis(), -1);
819    }
820
821    #[test]
822    fn test_parse_scheduled_time_datetime_out_of_range() {
823        // i64::MAX millis is well beyond representable chrono::DateTime range
824        let exts = HashMap::from([(FLOW_SCHEDULED_TIME_MILLIS.to_string(), i64::MAX.to_string())]);
825        let err = parse_scheduled_time_datetime(&exts).unwrap_err();
826        assert!(format!("{err}").contains("Out-of-range"));
827    }
828}
829
830#[cfg(test)]
831mod query_options_tests {
832    use super::*;
833
834    #[test]
835    fn test_parse_spill_options_from_toml() {
836        let toml_str = r#"
837experimental_spill_mode = "custom"
838experimental_spill_path = "/tmp/spill"
839experimental_spill_max_temp_directory_size = "50GiB"
840experimental_spill_compression = "zstd"
841experimental_memory_pool_policy = "fair"
842"#;
843        let opts: QueryOptions = toml::from_str(toml_str).unwrap();
844        assert_eq!(opts.experimental_spill_mode, QuerySpillMode::Custom);
845        assert_eq!(
846            opts.experimental_spill_path,
847            Some(PathBuf::from("/tmp/spill"))
848        );
849        assert_eq!(
850            opts.experimental_spill_max_temp_directory_size,
851            ReadableSize::gb(50)
852        );
853        assert_eq!(
854            opts.experimental_spill_compression,
855            QuerySpillCompression::Zstd
856        );
857        assert_eq!(
858            opts.experimental_memory_pool_policy,
859            QueryMemoryPoolPolicy::Fair
860        );
861    }
862
863    #[test]
864    fn test_parse_invalid_spill_option_values() {
865        for toml_str in [
866            r#"experimental_spill_mode = "invalid""#,
867            r#"experimental_spill_compression = "gzip""#,
868            r#"experimental_memory_pool_policy = "none""#,
869        ] {
870            assert!(
871                toml::from_str::<QueryOptions>(toml_str).is_err(),
872                "{toml_str}"
873            );
874        }
875    }
876}