///|
/// Cron expression frequency and schedule analyzer.
/// Analyzes how many times a cron expression fires per time period,
/// schedule density, and other statistical properties.
///|
/// Result of a frequency analysis.
pub struct FrequencyAnalysis {
per_minute : Int // max executions per minute
per_hour : Int // max executions per hour
per_day : Int // max executions per day
per_week : Int // max executions per week
per_month : Int // max executions per month (best case)
per_year : Int // max executions per year
max_gap_minutes : Int // longest gap between executions (minutes)
avg_gap_minutes : Int // average gap between executions (minutes)
}
///|
/// Analyze the frequency of a cron expression.
/// Returns statistics about how often the expression fires.
pub fn analyze_frequency(fields : Array[CronField]) -> FrequencyAnalysis {
let per_minute = execs_per_minute(fields)
let per_hour = execs_per_hour(fields)
let per_day = execs_per_day(fields)
let per_week = execs_per_week(fields)
let per_month = execs_per_month(fields)
let per_year = execs_per_year(fields)
let max_gap = max_gap_between_executions(fields)
let avg_gap = avg_gap_between_executions(fields)
{
per_minute,
per_hour,
per_day,
per_week,
per_month,
per_year,
max_gap_minutes: max_gap,
avg_gap_minutes: avg_gap,
}
}
///|
/// Offset for 6-field format: index 0 = seconds, so minutes are at index 1.
fn field_offset(fields : Array[CronField]) -> Int {
if fields.length() == 6 {
1
} else {
0
}
}
///|
/// Maximum possible executions in a single minute.
fn execs_per_minute(fields : Array[CronField]) -> Int {
if fields.length() == 6 {
fields[0].values.length() // seconds determine per-minute rate
} else {
1 // standard cron: at most 1 per minute
}
}
///|
/// Maximum possible executions per hour.
fn execs_per_hour(fields : Array[CronField]) -> Int {
let off = field_offset(fields)
let mins = fields[off].values.length() // minute field
let secs = if fields.length() == 6 { fields[0].values.length() } else { 1 }
mins * secs
}
///|
/// Maximum possible executions per day.
fn execs_per_day(fields : Array[CronField]) -> Int {
let per_hour = execs_per_hour(fields)
let off = field_offset(fields)
let hours = fields[1 + off].values.length() // hour field
per_hour * hours
}
///|
/// Maximum possible executions per week.
fn execs_per_week(fields : Array[CronField]) -> Int {
let per_day = execs_per_day(fields)
let off = field_offset(fields)
let dom_field = fields[2 + off]
let dow_field = fields[3 + off + 1]
let dom_restricted = !field_is_all_v(dom_field)
let dow_restricted = !field_is_all_v(dow_field)
if dow_restricted {
// Count matching days of week
let matching_days = dow_field.values.length()
per_day * matching_days
} else if dom_restricted {
// Day of month covers all days in a 31-day month pattern
per_day * dom_field.values.length()
} else {
per_day * 7
}
}
///|
/// Maximum possible executions per month (best case = 31-day month).
fn execs_per_month(fields : Array[CronField]) -> Int {
let per_day = execs_per_day(fields)
let off = field_offset(fields)
let dom_field = fields[2 + off]
let dow_field = fields[4 + off]
let dom_restricted = !field_is_all_v(dom_field)
let dow_restricted = !field_is_all_v(dow_field)
let days_per_month = if dom_restricted {
dom_field.values.length()
} else if dow_restricted {
// Approximate: ~4.3 weeks * matching days
let weekly_matches = dow_field.values.length()
weekly_matches * 31 / 7
} else {
31
}
per_day * days_per_month
}
///|
/// Maximum possible executions per year.
fn execs_per_year(fields : Array[CronField]) -> Int {
let per_day = execs_per_day(fields)
let off = field_offset(fields)
let dom_field = fields[2 + off]
let month_field = fields[3 + off]
let dow_field = fields[4 + off]
let dom_restricted = !field_is_all_v(dom_field)
let dow_restricted = !field_is_all_v(dow_field)
let month_restricted = !field_is_all_v(month_field)
let days_per_year = if dom_restricted && month_restricted {
// Count actual days per year based on month and day combinations
dom_field.values.length() * month_field.values.length()
} else if dom_restricted {
dom_field.values.length() * 12
} else if dow_restricted {
// Approx 52 weeks * matching weekdays
dow_field.values.length() * 52
} else {
365
}
per_day * days_per_year
}
///|
/// Estimate the maximum gap between consecutive executions, in minutes.
fn max_gap_between_executions(fields : Array[CronField]) -> Int {
let off = field_offset(fields)
let hour_field = fields[1 + off]
let minute_field = fields[off]
let min_values = minute_field.values
let hour_values = hour_field.values
if min_values.length() == 0 {
return 525600 // effectively never
}
// Find max gap between consecutive minutes
let mut max_minute_gap = 0
for i = 0; i < min_values.length() - 1; i = i + 1 {
let gap = min_values[i + 1] - min_values[i]
if gap > max_minute_gap {
max_minute_gap = gap
}
}
// Also check wrap-around: 59 - last + first + 1
let wrap_gap = 60 - min_values[min_values.length() - 1] + min_values[0]
if wrap_gap > max_minute_gap {
max_minute_gap = wrap_gap
}
// Find max gap between consecutive hours
let mut max_hour_gap = 0
if hour_values.length() > 0 {
for i = 0; i < hour_values.length() - 1; i = i + 1 {
let gap = hour_values[i + 1] - hour_values[i]
if gap > max_hour_gap {
max_hour_gap = gap
}
}
let wrap_hour_gap = 24 -
hour_values[hour_values.length() - 1] +
hour_values[0]
if wrap_hour_gap > max_hour_gap {
max_hour_gap = wrap_hour_gap
}
}
// The max gap is the larger of:
// 1. The gap within an hour (max_minute_gap minutes)
// 2. The gap between the last minute of one hour and first of the next
if max_hour_gap > 0 {
let inter_hour_gap = 60 -
min_values[min_values.length() - 1] +
min_values[0] +
(max_hour_gap - 1) * 60
if inter_hour_gap > max_minute_gap {
max_minute_gap = inter_hour_gap
}
}
max_minute_gap
}
///|
/// Estimate average gap between executions, in minutes.
fn avg_gap_between_executions(fields : Array[CronField]) -> Int {
let per_day = execs_per_day(fields)
if per_day == 0 {
return 525600
}
// Average gap = total minutes in a day / number of executions per day
1440 / per_day
}
///|
/// Check if a field represents all values.
fn field_is_all_v(field : CronField) -> Bool {
let (min, max) = field_range(field.field_type)
if field.values.length() != max - min + 1 {
return false
}
let mut expected = min
for i = 0; i < field.values.length(); i = i + 1 {
if field.values[i] != expected {
return false
}
expected = expected + 1
}
true
}
///|
/// Format a FrequencyAnalysis as a human-readable string.
pub fn frequency_to_string(analysis : FrequencyAnalysis) -> String {
let mut result = ""
result = result + "Frequency Analysis:\n"
result = result + " Per minute: " + analysis.per_minute.to_string() + "\n"
result = result + " Per hour: " + analysis.per_hour.to_string() + "\n"
result = result + " Per day: " + analysis.per_day.to_string() + "\n"
result = result + " Per week: " + analysis.per_week.to_string() + "\n"
result = result + " Per month: " + analysis.per_month.to_string() + "\n"
result = result + " Per year: " + analysis.per_year.to_string() + "\n"
result = result +
" Max gap: " +
analysis.max_gap_minutes.to_string() +
" minutes\n"
result = result +
" Avg gap: " +
analysis.avg_gap_minutes.to_string() +
" minutes"
result
}
///|
/// Get a schedule density as a category.
pub enum ScheduleDensity {
VerySparse // < 1 per day
Sparse // 1-5 per day
Moderate // 6-24 per day
Frequent // 25-144 per day
Dense // > 144 per day
}
///|
/// Classify the schedule density.
pub fn classify_density(analysis : FrequencyAnalysis) -> ScheduleDensity {
let per_day = analysis.per_day
let per_year = analysis.per_year
// If it fires less than once per day on average over a year
if per_year < 365 {
VerySparse
} else if per_day < 1 {
VerySparse
} else if per_day <= 5 {
Sparse
} else if per_day <= 24 {
Moderate
} else if per_day <= 144 {
Frequent
} else {
Dense
}
}
///|
/// Get a human-readable density label.
pub fn density_to_string(density : ScheduleDensity) -> String {
match density {
VerySparse => "Very sparse (less than once per day)"
Sparse => "Sparse (1-5 times per day)"
Moderate => "Moderate (6-24 times per day)"
Frequent => "Frequent (25-144 times per day)"
Dense => "Dense (more than 144 times per day)"
}
}
///|
/// Check if a cron expression would ever fire.
pub fn is_impossible(fields : Array[CronField]) -> Bool {
// Check for Feb 30 or other impossible day-month combinations
let off = field_offset(fields)
let dom_field = fields[2 + off]
let month_field = fields[3 + off]
// If day-of-month has values > 30 and month only includes February
if !field_is_all_v(month_field) {
let month_vals = month_field.values
let only_feb = month_vals.length() == 1 && month_vals[0] == 2
if only_feb {
for i = 0; i < dom_field.values.length(); i = i + 1 {
if dom_field.values[i] > 29 {
return true
}
}
}
// Check each month-day combination
let mut possible = false
for mi = 0; mi < month_vals.length() && !possible; mi = mi + 1 {
let m = month_vals[mi]
if m < 1 || m > 12 {
return true
}
for di = 0; di < dom_field.values.length() && !possible; di = di + 1 {
let d = dom_field.values[di]
if d >= 1 && d <= 31 {
// Check rough validity (Feb 30 is impossible regardless of year)
if m == 2 && d > 29 {
// impossible
} else if (m == 4 || m == 6 || m == 9 || m == 11) && d == 31 {
// impossible
} else {
possible = true
}
}
}
}
!possible
} else {
false
}
}