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:
discord9
2026-09-04 02:39:59 +00:00
committed by GitHub
parent 84bd993131
commit ad7b0ace64
22 changed files with 1702 additions and 151 deletions
+3 -1
View File
@@ -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()
+228 -57
View File
@@ -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]
+107 -9
View File
@@ -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
+117
View File
@@ -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 {