mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-09-09 23:12:15 +00:00
feat(flow): support eval schedule offsets (#8878)
* feat(flow): support eval schedule offsets Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(flow): remove redundant schedule assertion Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * refactor(flow): trim eval offset compatibility scope Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(flow): trim eval offset edge coverage Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * docs(flow): trim eval offset comment noise Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(flow): address eval offset review feedback Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test(compat): cover Flow eval offset persistence Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
@@ -387,7 +387,9 @@ impl FlowDualEngine {
|
||||
comment: Some(info.comment().clone()),
|
||||
sql: info.raw_sql().clone(),
|
||||
flow_options: info.options().clone(),
|
||||
eval_schedule: effective_eval_schedule_from_flow_info(&info),
|
||||
eval_schedule: effective_eval_schedule_from_flow_info(&info)
|
||||
.map_err(BoxedError::new)
|
||||
.context(ExternalSnafu)?,
|
||||
query_ctx: info
|
||||
.query_context()
|
||||
.clone()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
pub use common_meta::key::flow::flow_info::{FlowMissedTickPolicy, FlowScheduleConfig};
|
||||
use snafu::ensure;
|
||||
|
||||
use crate::error::{InvalidQuerySnafu, Result};
|
||||
use crate::error::{InvalidQuerySnafu, Result, UnexpectedSnafu};
|
||||
|
||||
/// Schedule for an `EVAL INTERVAL` flow.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -72,6 +72,29 @@ impl EvalSchedule {
|
||||
)
|
||||
}
|
||||
);
|
||||
// The anchor defines the epoch phase `anchor + k * interval`; it
|
||||
// must be a valid offset within one interval.
|
||||
ensure!(
|
||||
c.anchor_secs >= 0 && c.anchor_secs < interval_secs,
|
||||
InvalidQuerySnafu {
|
||||
reason: format!(
|
||||
"Invalid FlowScheduleConfig.anchor_secs: must be in [0, {interval_secs}), got {}",
|
||||
c.anchor_secs
|
||||
)
|
||||
}
|
||||
);
|
||||
// The start must be phase-consistent with the anchor (on an
|
||||
// `anchor + k * interval` boundary) and not before the anchor.
|
||||
ensure!(
|
||||
c.start_secs >= c.anchor_secs
|
||||
&& (c.start_secs - c.anchor_secs) % interval_secs == 0,
|
||||
InvalidQuerySnafu {
|
||||
reason: format!(
|
||||
"Invalid FlowScheduleConfig.start_secs: must be on an anchor + k * interval boundary and >= anchor, got start={}, anchor={}, interval={}",
|
||||
c.start_secs, c.anchor_secs, interval_secs
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
Self {
|
||||
interval_secs,
|
||||
@@ -96,30 +119,53 @@ impl EvalSchedule {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Returns the next scheduled time strictly after `cursor_secs`.
|
||||
pub fn next_scheduled_time_after(&self, cursor_secs: i64) -> i64 {
|
||||
/// Returns the next scheduled time strictly after `cursor_secs`, on the
|
||||
/// `anchor + k * interval` lattice.
|
||||
///
|
||||
/// Fallible: a non-positive interval or a next boundary that does not fit
|
||||
/// in `i64` yields an explicit error instead of a saturated non-phase
|
||||
/// value such as `i64::MAX`.
|
||||
pub fn next_scheduled_time_after(&self, cursor_secs: i64) -> Result<i64> {
|
||||
next_in_sequence(cursor_secs, self.start_secs, self.interval_secs)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_in_sequence(cursor: i64, start: i64, interval: i64) -> i64 {
|
||||
if interval <= 0 {
|
||||
return cursor.saturating_add(1).max(start);
|
||||
}
|
||||
if cursor < start {
|
||||
return start;
|
||||
}
|
||||
/// The smallest `start + k * interval` value that is strictly after `cursor`
|
||||
/// (`start` itself lies on the `anchor + k * interval` lattice, so every
|
||||
/// result is phase-consistent with the anchor). All arithmetic happens in
|
||||
/// `i128`: `cursor - start` cannot overflow and the result is either exactly
|
||||
/// on the lattice or an explicit error.
|
||||
fn next_in_sequence(cursor: i64, start: i64, interval: i64) -> Result<i64> {
|
||||
ensure!(
|
||||
interval > 0,
|
||||
InvalidQuerySnafu {
|
||||
reason: format!("Invalid eval interval: must be positive, got {interval}")
|
||||
}
|
||||
);
|
||||
let interval = i128::from(interval);
|
||||
let start = i128::from(start);
|
||||
let cursor = i128::from(cursor);
|
||||
|
||||
let k = (cursor - start) / interval;
|
||||
start.saturating_add((k + 1).saturating_mul(interval))
|
||||
let next = if cursor < start {
|
||||
start
|
||||
} else {
|
||||
let k = (cursor - start) / interval;
|
||||
start + (k + 1) * interval
|
||||
};
|
||||
|
||||
i64::try_from(next).map_err(|_| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot advance the eval schedule past cursor {cursor}: the next scheduled time {next} does not fit in i64 (start={start}, interval={interval})"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
fn first_due_in_sequence(cursor: i64, start: i64, interval: i64) -> i64 {
|
||||
if interval <= 0 {
|
||||
return cursor.saturating_add(1).max(start);
|
||||
}
|
||||
fn first_due_in_sequence(cursor: i64, start: i64, interval: i64) -> Result<i64> {
|
||||
if cursor < start {
|
||||
start
|
||||
Ok(start)
|
||||
} else {
|
||||
next_in_sequence(cursor, start, interval)
|
||||
}
|
||||
@@ -139,59 +185,112 @@ pub struct DueScheduledTimes {
|
||||
}
|
||||
|
||||
/// Select due scheduled times `<= wall_now_secs` without materializing all missed ticks.
|
||||
///
|
||||
/// Fallible: a non-positive interval or a scheduled time that does not fit in
|
||||
/// `i64` yields an explicit error instead of silently producing saturated
|
||||
/// non-phase timestamps.
|
||||
pub fn select_due_scheduled_times(
|
||||
schedule: &EvalSchedule,
|
||||
cursor_secs: i64,
|
||||
wall_now_secs: i64,
|
||||
) -> Option<DueScheduledTimes> {
|
||||
if schedule.interval_secs <= 0 {
|
||||
return None;
|
||||
}
|
||||
) -> Result<DueScheduledTimes> {
|
||||
let interval = schedule.interval_secs;
|
||||
ensure!(
|
||||
interval > 0,
|
||||
InvalidQuerySnafu {
|
||||
reason: format!("Invalid eval interval: must be positive, got {interval}")
|
||||
}
|
||||
);
|
||||
|
||||
let first_due = first_due_in_sequence(cursor_secs, schedule.start_secs, schedule.interval_secs);
|
||||
let first_due = first_due_in_sequence(cursor_secs, schedule.start_secs, interval)?;
|
||||
if first_due > wall_now_secs {
|
||||
return Some(DueScheduledTimes {
|
||||
return Ok(DueScheduledTimes {
|
||||
scheduled_times_secs: vec![],
|
||||
skipped: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let total_count = ((wall_now_secs - first_due) / schedule.interval_secs) as u64 + 1;
|
||||
// Count and select due scheduled times in i128 so every value stays
|
||||
// exactly on the `anchor + k * interval` lattice; a value beyond `i64` is
|
||||
// an explicit error, never a saturated non-phase timestamp.
|
||||
let first_due = i128::from(first_due);
|
||||
let wall_now = i128::from(wall_now_secs);
|
||||
let interval = i128::from(interval);
|
||||
|
||||
let total_count = (wall_now - first_due) / interval + 1;
|
||||
// `first_due >= 0` and `wall_now <= i64::MAX`, so this always fits in u64.
|
||||
let total_count = u64::try_from(total_count).map_err(|_| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot count due eval scheduled times up to {wall_now}: {total_count} does not fit in u64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})?;
|
||||
|
||||
match schedule.missed_tick_policy {
|
||||
FlowMissedTickPolicy::Skip => {
|
||||
let last = first_due + (total_count as i64 - 1) * schedule.interval_secs;
|
||||
Some(DueScheduledTimes {
|
||||
// Keep only the latest due scheduled time; it is still on-lattice
|
||||
// and `<= wall_now`.
|
||||
let last = i64::try_from(first_due + i128::from(total_count - 1) * interval)
|
||||
.map_err(|_| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot compute the latest due eval scheduled time (first_due={first_due}, interval={interval}, count={total_count}): result does not fit in i64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})?;
|
||||
Ok(DueScheduledTimes {
|
||||
scheduled_times_secs: vec![last],
|
||||
skipped: total_count.saturating_sub(1),
|
||||
skipped: total_count - 1,
|
||||
})
|
||||
}
|
||||
FlowMissedTickPolicy::BoundedCatchUp => {
|
||||
let cutoff = wall_now_secs.saturating_sub(schedule.max_lag_secs);
|
||||
// The cutoff is computed in i128: `wall_now - max_lag` may
|
||||
// legitimately underflow i64 (a cutoff before the Unix epoch) and
|
||||
// must not saturate to a wrong value.
|
||||
let cutoff = wall_now - i128::from(schedule.max_lag_secs);
|
||||
let skipped_by_cutoff = if first_due >= cutoff {
|
||||
0
|
||||
} else {
|
||||
((cutoff - first_due + schedule.interval_secs - 1) / schedule.interval_secs) as u64
|
||||
// ceil((cutoff - first_due) / interval), capped at u64::MAX
|
||||
// before the `.min(total_count)` below.
|
||||
let skipped = (cutoff - first_due + interval - 1) / interval;
|
||||
u64::try_from(skipped).unwrap_or(u64::MAX)
|
||||
}
|
||||
.min(total_count);
|
||||
|
||||
let remaining = total_count.saturating_sub(skipped_by_cutoff);
|
||||
let remaining = total_count - skipped_by_cutoff;
|
||||
if remaining == 0 {
|
||||
return Some(DueScheduledTimes {
|
||||
return Ok(DueScheduledTimes {
|
||||
scheduled_times_secs: vec![],
|
||||
skipped: total_count,
|
||||
});
|
||||
}
|
||||
|
||||
// max_lag_secs decides which missed scheduled times are recent enough to
|
||||
// run; max_runs caps how many of those times we execute
|
||||
// back-to-back in one scheduler pass.
|
||||
let keep_count = remaining.min(schedule.max_runs as u64);
|
||||
let keep_start = skipped_by_cutoff + remaining.saturating_sub(keep_count);
|
||||
let scheduled_times_secs = (0..keep_count)
|
||||
.map(|i| first_due + (keep_start as i64 + i as i64) * schedule.interval_secs)
|
||||
.collect::<Vec<_>>();
|
||||
// max_lag decides which missed scheduled times are recent enough to
|
||||
// run; max_runs caps how many of those times execute back-to-back
|
||||
// in one scheduler pass.
|
||||
let keep_count = remaining.min(u64::from(schedule.max_runs));
|
||||
let keep_start = skipped_by_cutoff + remaining - keep_count;
|
||||
let mut scheduled_times_secs = Vec::with_capacity(keep_count as usize);
|
||||
for i in 0..keep_count {
|
||||
let t = i64::try_from(
|
||||
first_due + (i128::from(keep_start) + i128::from(i)) * interval,
|
||||
)
|
||||
.map_err(|_| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot compute a due eval scheduled time (first_due={first_due}, interval={interval}, index={i}): result does not fit in i64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})?;
|
||||
scheduled_times_secs.push(t);
|
||||
}
|
||||
|
||||
Some(DueScheduledTimes {
|
||||
Ok(DueScheduledTimes {
|
||||
scheduled_times_secs,
|
||||
skipped: total_count - keep_count,
|
||||
})
|
||||
@@ -200,12 +299,15 @@ pub fn select_due_scheduled_times(
|
||||
}
|
||||
|
||||
/// Ceils `time` to the next `anchor + k * interval` boundary.
|
||||
pub fn ceil_to_boundary(time: i64, anchor: i64, interval: i64) -> i64 {
|
||||
///
|
||||
/// Fallible: if the next boundary does not fit in `i64`, an explicit error is
|
||||
/// returned instead of clamping to a non-phase value such as `i64::MAX`.
|
||||
pub fn ceil_to_boundary(time: i64, anchor: i64, interval: i64) -> Result<i64> {
|
||||
if interval <= 0 {
|
||||
return time;
|
||||
return Ok(time);
|
||||
}
|
||||
if time <= anchor {
|
||||
return anchor;
|
||||
return Ok(anchor);
|
||||
}
|
||||
|
||||
let diff = i128::from(time) - i128::from(anchor);
|
||||
@@ -213,7 +315,14 @@ pub fn ceil_to_boundary(time: i64, anchor: i64, interval: i64) -> i64 {
|
||||
let k = (diff + interval - 1) / interval;
|
||||
let boundary = i128::from(anchor) + k * interval;
|
||||
|
||||
boundary.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
|
||||
i64::try_from(boundary).map_err(|_| {
|
||||
crate::error::UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot align time {time} to the next `anchor + k * interval` boundary (anchor={anchor}, interval={interval}): result {boundary} does not fit in i64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -239,7 +348,8 @@ mod test {
|
||||
fn config(policy: FlowMissedTickPolicy) -> FlowScheduleConfig {
|
||||
FlowScheduleConfig {
|
||||
anchor_secs: 10,
|
||||
start_secs: 70,
|
||||
// phase-consistent: 310 = anchor(10) + 1 * interval(300)
|
||||
start_secs: 310,
|
||||
missed_tick_policy: policy,
|
||||
catchup_max_runs: 4,
|
||||
catchup_max_lag_secs: 600,
|
||||
@@ -248,14 +358,15 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn ceil_to_boundary_handles_anchor_and_interval_edges() {
|
||||
assert_eq!(ceil_to_boundary(-10, 0, 60), 0);
|
||||
assert_eq!(ceil_to_boundary(0, 0, 60), 0);
|
||||
assert_eq!(ceil_to_boundary(1, 0, 60), 60);
|
||||
assert_eq!(ceil_to_boundary(60, 0, 60), 60);
|
||||
assert_eq!(ceil_to_boundary(101, 100, 60), 160);
|
||||
assert_eq!(ceil_to_boundary(50, 0, 0), 50);
|
||||
assert_eq!(ceil_to_boundary(i64::MAX, 0, 60), i64::MAX);
|
||||
assert_eq!(ceil_to_boundary(i64::MAX - 1, i64::MIN, 60), i64::MAX);
|
||||
assert_eq!(ceil_to_boundary(-10, 0, 60).unwrap(), 0);
|
||||
assert_eq!(ceil_to_boundary(0, 0, 60).unwrap(), 0);
|
||||
assert_eq!(ceil_to_boundary(1, 0, 60).unwrap(), 60);
|
||||
assert_eq!(ceil_to_boundary(60, 0, 60).unwrap(), 60);
|
||||
assert_eq!(ceil_to_boundary(101, 100, 60).unwrap(), 160);
|
||||
assert_eq!(ceil_to_boundary(50, 0, 0).unwrap(), 50);
|
||||
// Never clamp to the non-phase i64::MAX: the next boundary does not fit.
|
||||
assert!(ceil_to_boundary(i64::MAX, 0, 60).is_err());
|
||||
assert!(ceil_to_boundary(i64::MAX - 1, i64::MIN, 60).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -269,7 +380,7 @@ mod test {
|
||||
.unwrap();
|
||||
assert_eq!(from_typed.interval_secs, 300);
|
||||
assert_eq!(from_typed.anchor_secs, 10);
|
||||
assert_eq!(from_typed.start_secs, 70);
|
||||
assert_eq!(from_typed.start_secs, 310);
|
||||
assert_eq!(from_typed.missed_tick_policy, FlowMissedTickPolicy::Skip);
|
||||
assert_eq!(from_typed.max_runs, 4);
|
||||
assert_eq!(from_typed.max_lag_secs, 600);
|
||||
@@ -291,12 +402,72 @@ mod test {
|
||||
assert!(EvalSchedule::from_config(Some(300), Some(&c)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_anchor_due_selection_follows_phase() {
|
||||
// anchor=120 (i.e. `EVAL OFFSET '2 minutes'`), interval=3600:
|
||||
// boundaries at :02 every hour. start=3720 (120 + 3600).
|
||||
let s = EvalSchedule {
|
||||
interval_secs: 3600,
|
||||
anchor_secs: 120,
|
||||
start_secs: 3720,
|
||||
missed_tick_policy: FlowMissedTickPolicy::BoundedCatchUp,
|
||||
max_runs: 3,
|
||||
max_lag_secs: 3600,
|
||||
};
|
||||
assert_eq!(
|
||||
select_due_scheduled_times(&s, 0, 100)
|
||||
.unwrap()
|
||||
.scheduled_times_secs,
|
||||
Vec::<i64>::new()
|
||||
);
|
||||
// From 3720 on, every selected time must be on the :02 phase.
|
||||
let due = select_due_scheduled_times(&s, 0, 3720).unwrap();
|
||||
assert_eq!(due.scheduled_times_secs, vec![3720]);
|
||||
let due = select_due_scheduled_times(&s, 3720, 7320).unwrap();
|
||||
assert_eq!(due.scheduled_times_secs, vec![7320]);
|
||||
for t in &due.scheduled_times_secs {
|
||||
assert_eq!((t - 120) % 3600, 0);
|
||||
}
|
||||
assert_eq!(s.next_scheduled_time_after(3720).unwrap(), 7320);
|
||||
assert_eq!(s.next_scheduled_time_after(7300).unwrap(), 7320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_scheduled_time_after_respects_start_sequence() {
|
||||
let s = schedule(50, FlowMissedTickPolicy::BoundedCatchUp, 3, 300);
|
||||
assert_eq!(s.next_scheduled_time_after(0), 50);
|
||||
assert_eq!(s.next_scheduled_time_after(50), 110);
|
||||
assert_eq!(s.next_scheduled_time_after(100), 110);
|
||||
assert_eq!(s.next_scheduled_time_after(0).unwrap(), 50);
|
||||
assert_eq!(s.next_scheduled_time_after(50).unwrap(), 110);
|
||||
assert_eq!(s.next_scheduled_time_after(100).unwrap(), 110);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_i64_max_advancement_is_exact_or_explicit_error() {
|
||||
// anchor=0, interval=60: the next boundary after i64::MAX - 60 is
|
||||
// 9223372036854775800, still in range and exactly on the lattice.
|
||||
let s = schedule(0, FlowMissedTickPolicy::Skip, 5, 3600);
|
||||
let cursor = i64::MAX - 60;
|
||||
let next = s.next_scheduled_time_after(cursor).unwrap();
|
||||
assert_eq!(next, 9223372036854775800);
|
||||
assert_eq!(next % 60, 0);
|
||||
|
||||
// Advancing past the last representable boundary is an explicit error,
|
||||
// never a saturated non-phase value like i64::MAX.
|
||||
let err = s
|
||||
.next_scheduled_time_after(9223372036854775800)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("does not fit in i64"));
|
||||
|
||||
// A non-positive interval is an explicit error, not a saturating
|
||||
// `cursor + 1` result.
|
||||
let invalid = EvalSchedule {
|
||||
interval_secs: 0,
|
||||
anchor_secs: 0,
|
||||
start_secs: 0,
|
||||
missed_tick_policy: FlowMissedTickPolicy::Skip,
|
||||
max_runs: 3,
|
||||
max_lag_secs: 900,
|
||||
};
|
||||
assert!(invalid.next_scheduled_time_after(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -79,6 +79,61 @@ fn wall_clock_unix_secs() -> i64 {
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
/// Initial scheduler cursor for `start_scheduled_loop`: exactly one interval
|
||||
/// before `start_secs` so the first due scheduled time is `start_secs` itself.
|
||||
///
|
||||
/// Fallible: a `start_secs - interval_secs` difference that does not fit in
|
||||
/// `i64` is an explicit error instead of a saturated cursor that would make
|
||||
/// the first due scheduled time `start_secs + interval_secs` and silently skip
|
||||
/// the `start_secs` tick.
|
||||
fn initial_schedule_cursor(start_secs: i64, interval_secs: i64) -> Result<i64, Error> {
|
||||
let cursor = i128::from(start_secs) - i128::from(interval_secs);
|
||||
i64::try_from(cursor).map_err(|_| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot compute the initial eval schedule cursor one interval before start {start_secs} (interval={interval_secs}): {cursor} does not fit in i64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whole seconds to sleep until the next scheduled time `next`, measured from
|
||||
/// the current wall clock `wall_now_secs`.
|
||||
///
|
||||
/// Fallible: `next` must be strictly after `wall_now_secs` and the difference
|
||||
/// must fit in `u64`. In practice `i64::MAX - i64::MIN` is exactly `u64::MAX`,
|
||||
/// so the difference always fits once `next > wall_now_secs`; the explicit
|
||||
/// error keeps the scheduled loop panic-free and wrap-free regardless.
|
||||
fn sleep_delta_secs(next: i64, wall_now_secs: i64) -> Result<u64, Error> {
|
||||
let delta = i128::from(next) - i128::from(wall_now_secs);
|
||||
u64::try_from(delta).map_err(|_| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot sleep until the next scheduled time {next}: the delta from wall clock {wall_now_secs} is {delta} seconds, which does not fit in u64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// Scheduled time in seconds converted to milliseconds for the
|
||||
/// `FLOW_SCHEDULED_TIME_MILLIS` extension.
|
||||
///
|
||||
/// Fallible: a seconds value whose millisecond product does not fit in `i64`
|
||||
/// is an explicit error instead of a saturated `i64::MAX` that would silently
|
||||
/// misrepresent the logical scheduled time.
|
||||
fn scheduled_time_millis(scheduled_time_secs: i64) -> Result<i64, Error> {
|
||||
scheduled_time_secs.checked_mul(1000).ok_or_else(|| {
|
||||
UnexpectedSnafu {
|
||||
reason: format!(
|
||||
"Cannot convert scheduled time {scheduled_time_secs}s to milliseconds: the product exceeds i64"
|
||||
),
|
||||
}
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// The task's config, immutable once created
|
||||
#[derive(Clone)]
|
||||
pub struct TaskConfig {
|
||||
@@ -1020,8 +1075,20 @@ impl BatchingTask {
|
||||
};
|
||||
|
||||
// Initial cursor is one interval before start so the first due
|
||||
// scheduled time is `start_secs`.
|
||||
let mut cursor_secs = schedule.start_secs.saturating_sub(schedule.interval_secs);
|
||||
// scheduled time is `start_secs`. An unrepresentable difference is an
|
||||
// explicit error, never a saturated cursor that would silently skip
|
||||
// the first scheduled tick.
|
||||
let mut cursor_secs =
|
||||
match initial_schedule_cursor(schedule.start_secs, schedule.interval_secs) {
|
||||
Ok(cursor) => cursor,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Flow {}: invalid eval schedule, exiting loop: {}",
|
||||
flow_id_str, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Flow {}: entering scheduled loop, interval={}s, start={}, anchor={}, policy={:?}, max_runs={}, max_lag={}s",
|
||||
@@ -1042,11 +1109,11 @@ impl BatchingTask {
|
||||
let wall_now_secs = wall_clock_unix_secs();
|
||||
|
||||
let due = match select_due_scheduled_times(&schedule, cursor_secs, wall_now_secs) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Flow {}: Invalid schedule (interval <= 0), exiting loop",
|
||||
flow_id_str
|
||||
"Flow {}: invalid eval schedule, exiting loop: {}",
|
||||
flow_id_str, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1063,14 +1130,32 @@ impl BatchingTask {
|
||||
}
|
||||
|
||||
// No due yet — sleep until the next scheduled time.
|
||||
let next = schedule.next_scheduled_time_after(cursor_secs);
|
||||
let next = match schedule.next_scheduled_time_after(cursor_secs) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Flow {}: cannot advance eval schedule past cursor {cursor_secs}: {e}; exiting loop",
|
||||
flow_id_str
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if next <= wall_now_secs {
|
||||
// Shouldn't happen given select_due_scheduled_times returned empty,
|
||||
// but guard against clock skew / logic error.
|
||||
cursor_secs = wall_now_secs;
|
||||
continue;
|
||||
}
|
||||
let wait_secs = (next - wall_now_secs) as u64;
|
||||
let wait_secs = match sleep_delta_secs(next, wall_now_secs) {
|
||||
Ok(wait_secs) => wait_secs,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Flow {}: cannot sleep until next scheduled time {}: {e}; exiting loop",
|
||||
flow_id_str, next
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let wait_dur = Duration::from_secs(wait_secs);
|
||||
debug!(
|
||||
"Flow {}: no due scheduled times, sleeping for {}s until next scheduled time at {}",
|
||||
@@ -1259,6 +1344,19 @@ impl BatchingTask {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to milliseconds before touching the task state so an
|
||||
// unrepresentable scheduled time fails as an explicit error without
|
||||
// ever installing a saturated (off-phase) extension value.
|
||||
let scheduled_time_millis = match scheduled_time_millis(scheduled_time_secs) {
|
||||
Ok(millis) => millis,
|
||||
Err(e) => {
|
||||
return ExecuteOnceOutcome {
|
||||
new_query: None,
|
||||
result: Err(e),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Clone the current QueryContext and add the scheduled time
|
||||
// extension, then swap it into the task state for this attempt.
|
||||
let old_ctx = {
|
||||
@@ -1267,7 +1365,7 @@ impl BatchingTask {
|
||||
let mut new_ctx = (*old).clone();
|
||||
new_ctx.set_extension(
|
||||
query::options::FLOW_SCHEDULED_TIME_MILLIS,
|
||||
(scheduled_time_secs.saturating_mul(1000)).to_string(),
|
||||
scheduled_time_millis.to_string(),
|
||||
);
|
||||
state.query_ctx = Arc::new(new_ctx);
|
||||
old
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::batching_mode::checkpoint::{
|
||||
CHECKPOINT_DECISION_ADVANCE, CHECKPOINT_DECISION_FALLBACK, CHECKPOINT_REASON_NONE,
|
||||
FlowCheckpointDecision, FlowQueryFallbackReason,
|
||||
};
|
||||
use crate::batching_mode::eval_schedule::{FlowMissedTickPolicy, FlowScheduleConfig};
|
||||
use crate::batching_mode::state::CheckpointMode;
|
||||
use crate::batching_mode::time_window::find_time_window_expr;
|
||||
use crate::test_utils::create_test_query_engine;
|
||||
@@ -142,6 +143,77 @@ async fn test_incremental_read_is_disabled_by_default() {
|
||||
assert!(task.state.read().unwrap().is_incremental_disabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_aggregate_scheduled_sql_honors_eval_offset_phase() {
|
||||
// A non-aggregate SQL flow with `EVAL INTERVAL` runs as an explicit
|
||||
// full-query flow on the batching scheduler. The typed schedule must reach
|
||||
// the task config unchanged and the offset must not be silently ignored:
|
||||
// due scheduled times follow the `anchor + k * interval` phase.
|
||||
let query = "SELECT number, ts FROM numbers_with_ts";
|
||||
let query_engine = create_test_query_engine();
|
||||
let ctx = QueryContext::arc();
|
||||
let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), query, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
let schedule = EvalSchedule::from_config(
|
||||
Some(3600),
|
||||
Some(&FlowScheduleConfig {
|
||||
anchor_secs: 120, // `EVAL OFFSET '2 minutes'`
|
||||
start_secs: 3720, // 120 + 1 * 3600
|
||||
missed_tick_policy: FlowMissedTickPolicy::BoundedCatchUp,
|
||||
catchup_max_runs: 3,
|
||||
catchup_max_lag_secs: 3600,
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let task = BatchingTask::try_new(TaskArgs {
|
||||
flow_id: 1,
|
||||
query,
|
||||
plan,
|
||||
time_window_expr: None,
|
||||
expire_after: None,
|
||||
sink_table_name: [
|
||||
"greptime".to_string(),
|
||||
"public".to_string(),
|
||||
"scheduled_non_aggr_sink".to_string(),
|
||||
],
|
||||
source_table_names: vec![[
|
||||
"greptime".to_string(),
|
||||
"public".to_string(),
|
||||
"numbers_with_ts".to_string(),
|
||||
]],
|
||||
query_ctx: ctx,
|
||||
catalog_manager: query_engine.engine_state().catalog_manager().clone(),
|
||||
shutdown_rx: rx,
|
||||
batch_opts: Arc::new(BatchingModeOptions::default()),
|
||||
flow_eval_interval: Some(Duration::from_secs(3600)),
|
||||
eval_schedule: Some(schedule),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let stored = task.config.eval_schedule.as_ref().unwrap();
|
||||
assert_eq!(stored.anchor_secs, 120);
|
||||
assert_eq!(stored.start_secs, 3720);
|
||||
assert_eq!(stored.interval_secs, 3600);
|
||||
|
||||
// Due-time selection proves the offset is honored: the first due time is
|
||||
// on the `:02` phase (120 + k * 3600), never 0 or 3600.
|
||||
let due = select_due_scheduled_times(stored, 0, 3720).unwrap();
|
||||
assert_eq!(due.scheduled_times_secs, vec![3720]);
|
||||
for t in &due.scheduled_times_secs {
|
||||
assert_eq!((t - 120) % 3600, 0);
|
||||
}
|
||||
let due = select_due_scheduled_times(stored, 3720, 7320).unwrap();
|
||||
assert_eq!(due.scheduled_times_secs, vec![7320]);
|
||||
for t in &due.scheduled_times_secs {
|
||||
assert_eq!((t - 120) % 3600, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dirty_time_windows_uses_batch_opts() {
|
||||
let task = new_test_task_engine_and_plan_with_query_and_opts(
|
||||
@@ -764,6 +836,51 @@ async fn test_scheduled_time_now_is_bound_to_selected_attempt() {
|
||||
assert!(!sent_sql.is_empty());
|
||||
}
|
||||
|
||||
/// The scheduled-loop logical-time arithmetic must never clamp, wrap, or
|
||||
/// panic near the `i64` boundaries: unrepresentable values are explicit
|
||||
/// errors, representable values are exact.
|
||||
#[test]
|
||||
fn test_scheduled_loop_arithmetic_near_i64_boundary() {
|
||||
assert_eq!(initial_schedule_cursor(3720, 3600).unwrap(), 120);
|
||||
assert_eq!(
|
||||
initial_schedule_cursor(i64::MAX, 3600).unwrap(),
|
||||
i64::MAX - 3600
|
||||
);
|
||||
// start == i64::MIN cannot go one interval earlier: explicit error, never
|
||||
// a saturated cursor equal to start that would silently skip the first
|
||||
// scheduled tick.
|
||||
assert!(initial_schedule_cursor(i64::MIN, 3600).is_err());
|
||||
assert_eq!(
|
||||
initial_schedule_cursor(i64::MIN + 3600, 3600).unwrap(),
|
||||
i64::MIN
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
scheduled_time_millis(1_700_000_000).unwrap(),
|
||||
1_700_000_000_000
|
||||
);
|
||||
// Largest seconds value whose millisecond product still fits in i64.
|
||||
let max_secs = i64::MAX / 1000;
|
||||
assert_eq!(scheduled_time_millis(max_secs).unwrap(), max_secs * 1000);
|
||||
// One more second overflows: explicit error, never a saturated i64::MAX.
|
||||
let err = scheduled_time_millis(max_secs + 1).unwrap_err();
|
||||
assert!(err.to_string().contains("milliseconds"), "{err}");
|
||||
assert!(scheduled_time_millis(i64::MAX).is_err());
|
||||
|
||||
// The widest representable gap (i64::MIN..=i64::MAX) is exactly u64::MAX;
|
||||
// subtracting in i64 would panic in debug and wrap in release, so the
|
||||
// i128 path must return the exact u64 value.
|
||||
assert_eq!(
|
||||
sleep_delta_secs(i64::MAX, i64::MIN).unwrap(),
|
||||
u64::MAX,
|
||||
"i64::MAX - i64::MIN must be exactly u64::MAX, not a wrapped value"
|
||||
);
|
||||
assert_eq!(sleep_delta_secs(7320, 3720).unwrap(), 3600);
|
||||
// A negative delta (next <= wall_now, violating the caller's guard) is an
|
||||
// explicit error instead of a wrapped huge `as u64` sleep.
|
||||
assert!(sleep_delta_secs(100, 200).is_err());
|
||||
}
|
||||
|
||||
fn output_with_region_watermarks(
|
||||
watermarks: impl IntoIterator<Item = (u64, Option<u64>)>,
|
||||
) -> OutputWithMetrics {
|
||||
|
||||
Reference in New Issue
Block a user