Skip to main content

wowlab_sentinel/cron/
rollup.rs

1#![expect(
2    clippy::cast_precision_loss,
3    clippy::items_after_statements,
4    reason = "rollup statistics intentionally convert bounded database aggregates to f64 and colocate query row types"
5)]
6
7use async_trait::async_trait;
8use chrono::{Duration, NaiveDate, Utc};
9use wowlab_types::{constants::PROTO_DPS_SCALE, sim::FastMap};
10
11use super::CronJob;
12use crate::state::ServerState;
13
14const VARIANCE_EXPONENT: i32 = 2;
15const ROLLING_LOOKBACK_DAYS: i64 = 30;
16const ROLLING_WINDOWS: [i64; 3] = [7, 14, 30];
17
18#[derive(Debug)]
19pub(crate) struct RollupDailyJob {
20    schedule: String,
21}
22
23impl RollupDailyJob {
24    pub(crate) fn new(schedule: &str) -> Self {
25        Self {
26            schedule: schedule.to_owned(),
27        }
28    }
29}
30
31#[async_trait]
32impl CronJob for RollupDailyJob {
33    fn name(&self) -> &'static str {
34        "rollup_daily"
35    }
36
37    fn schedule(&self) -> &str {
38        &self.schedule
39    }
40
41    async fn run(&self, state: &ServerState) {
42        let yesterday = Utc::now().date_naive() - Duration::days(1);
43
44        if let Err(e) = run_daily_rollup(state, yesterday).await {
45            tracing::error!(error = %e, day = %yesterday, "Daily rollup failed");
46        }
47    }
48}
49
50async fn run_daily_rollup(
51    state: &ServerState,
52    day: NaiveDate,
53) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
54    let start = day
55        .and_hms_opt(0, 0, 0)
56        .ok_or("invalid midnight time for start")?
57        .and_utc();
58    let end = (day + Duration::days(1))
59        .and_hms_opt(0, 0, 0)
60        .ok_or("invalid midnight time for end")?
61        .and_utc();
62
63    struct JobRow {
64        season_id: String,
65        spec_id: i32,
66        result_pb: Vec<u8>,
67    }
68
69    let rows = sqlx::query_file_as!(JobRow, "queries/rollup_daily_fetch_jobs.sql", start, end)
70        .fetch_all(state.dbs.get::<crate::cron::CronDb>())
71        .await?;
72
73    if rows.is_empty() {
74        tracing::debug!(day = %day, "No completed jobs for daily rollup");
75
76        return Ok(());
77    }
78
79    // #t(rust_similar_structs) decoded database rows include iteration weight unlike engine running-stat snapshots
80    struct Decoded {
81        iterations: u64,
82        mean_dps: f64,
83        std_dps: f64,
84        min_dps: f64,
85        max_dps: f64,
86    }
87
88    let mut decode_failures = 0usize;
89    let groups: FastMap<(String, i32), Vec<Decoded>> = rows
90        .iter()
91        .filter_map(|row| {
92            if let Ok(core) = super::decode_result_core(row.result_pb.as_slice()) {
93                Some((
94                    (row.season_id.clone(), row.spec_id),
95                    Decoded {
96                        iterations: core.iterations,
97                        mean_dps: f64::from(core.mean_dps_x10) / PROTO_DPS_SCALE,
98                        std_dps: f64::from(core.std_dps_x10) / PROTO_DPS_SCALE,
99                        min_dps: f64::from(core.min_dps_x10) / PROTO_DPS_SCALE,
100                        max_dps: f64::from(core.max_dps_x10) / PROTO_DPS_SCALE,
101                    },
102                ))
103            } else {
104                decode_failures += 1;
105
106                None
107            }
108        })
109        .fold(FastMap::default(), |mut groups, (key, decoded)| {
110            groups.entry(key).or_default().push(decoded);
111
112            groups
113        });
114
115    if decode_failures > 0 {
116        tracing::warn!(decode_failures, "Failed to decode daily rollup results");
117    }
118
119    let mut upserted = 0u32;
120
121    for ((season_id, spec_id), jobs) in &groups {
122        let jobs_count = i64::try_from(jobs.len()).unwrap_or(i64::MAX);
123        let total_iters: f64 = jobs.iter().map(|j| j.iterations as f64).sum();
124
125        // #t(rust_floating_point_eq) exact zero-check is intentional division-by-zero guard
126        if total_iters == 0.0 {
127            continue;
128        }
129
130        let mean_dps = jobs
131            .iter()
132            .map(|j| j.mean_dps * j.iterations as f64)
133            .sum::<f64>()
134            / total_iters;
135
136        let min_dps = jobs.iter().map(|j| j.min_dps).fold(f64::INFINITY, f64::min);
137        let max_dps = jobs
138            .iter()
139            .map(|j| j.max_dps)
140            .fold(f64::NEG_INFINITY, f64::max);
141
142        let pooled_var = jobs
143            .iter()
144            .map(|j| {
145                let n = j.iterations as f64;
146                let within = j.std_dps * j.std_dps;
147                let between = (j.mean_dps - mean_dps).powi(VARIANCE_EXPONENT);
148
149                n * (within + between)
150            })
151            .sum::<f64>()
152            / total_iters;
153        let std_dps = pooled_var.sqrt();
154
155        sqlx::query_file!(
156            "queries/rollup_daily_upsert.sql",
157            season_id.as_str(),
158            *spec_id,
159            day,
160            jobs_count,
161            mean_dps,
162            std_dps,
163            min_dps,
164            max_dps
165        )
166        .execute(state.dbs.get::<crate::cron::CronDb>())
167        .await?;
168
169        upserted += 1;
170    }
171
172    tracing::info!(
173        day = %day,
174        specs = upserted,
175        jobs = rows.len(),
176        "Daily rollup completed"
177    );
178
179    Ok(())
180}
181
182#[derive(Debug)]
183pub(crate) struct RollupRollingJob {
184    schedule: String,
185}
186
187impl RollupRollingJob {
188    pub(crate) fn new(schedule: &str) -> Self {
189        Self {
190            schedule: schedule.to_owned(),
191        }
192    }
193}
194
195#[async_trait]
196impl CronJob for RollupRollingJob {
197    fn name(&self) -> &'static str {
198        "rollup_rolling"
199    }
200
201    fn schedule(&self) -> &str {
202        &self.schedule
203    }
204
205    async fn run(&self, state: &ServerState) {
206        let anchor = Utc::now().date_naive();
207
208        if let Err(e) = run_rolling_rollup(state, anchor).await {
209            tracing::error!(error = %e, anchor = %anchor, "Rolling rollup failed");
210        }
211    }
212}
213
214async fn run_rolling_rollup(
215    state: &ServerState,
216    anchor_day: NaiveDate,
217) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
218    let lower = anchor_day - Duration::days(ROLLING_LOOKBACK_DAYS);
219
220    struct DailyRow {
221        season_id: String,
222        spec_id: i32,
223        day: NaiveDate,
224        jobs_count: i64,
225        mean_dps: f64,
226        std_dps: f64,
227        min_dps: f64,
228        max_dps: f64,
229    }
230
231    let rows = sqlx::query_file_as!(
232        DailyRow,
233        "queries/rollup_rolling_fetch_daily.sql",
234        lower,
235        anchor_day
236    )
237    .fetch_all(state.dbs.get::<crate::cron::CronDb>())
238    .await?;
239
240    if rows.is_empty() {
241        tracing::debug!(anchor = %anchor_day, "No daily rollups for rolling windows");
242
243        return Ok(());
244    }
245
246    let groups: FastMap<(String, i32), Vec<&DailyRow>> =
247        rows.iter().fold(FastMap::default(), |mut groups, row| {
248            groups
249                .entry((row.season_id.clone(), row.spec_id))
250                .or_default()
251                .push(row);
252
253            groups
254        });
255
256    let windows = ROLLING_WINDOWS;
257    let mut upserted = 0u32;
258
259    for ((season_id, spec_id), daily_rows) in &groups {
260        for &window in &windows {
261            let cutoff = anchor_day - Duration::days(window);
262            let in_window: Vec<&DailyRow> = daily_rows
263                .iter()
264                .copied()
265                .filter(|r| r.day > cutoff && r.day <= anchor_day)
266                .collect();
267
268            if in_window.is_empty() {
269                continue;
270            }
271
272            let total_jobs: i64 = in_window.iter().map(|r| r.jobs_count).sum();
273
274            if total_jobs == 0 {
275                continue;
276            }
277
278            let grand_mean = in_window
279                .iter()
280                .map(|r| r.mean_dps * r.jobs_count as f64)
281                .sum::<f64>()
282                / total_jobs as f64;
283
284            let min_dps = in_window
285                .iter()
286                .map(|r| r.min_dps)
287                .fold(f64::INFINITY, f64::min);
288            let max_dps = in_window
289                .iter()
290                .map(|r| r.max_dps)
291                .fold(f64::NEG_INFINITY, f64::max);
292
293            let pooled_var = in_window
294                .iter()
295                .map(|r| {
296                    let n = r.jobs_count as f64;
297                    let within = r.std_dps * r.std_dps;
298                    let between = (r.mean_dps - grand_mean).powi(VARIANCE_EXPONENT);
299
300                    n * (within + between)
301                })
302                .sum::<f64>()
303                / total_jobs as f64;
304            let std_dps = pooled_var.sqrt();
305
306            sqlx::query_file!(
307                "queries/rollup_rolling_upsert.sql",
308                season_id.as_str(),
309                *spec_id,
310                anchor_day,
311                i32::try_from(window).unwrap_or(i32::MAX),
312                total_jobs,
313                grand_mean,
314                std_dps,
315                min_dps,
316                max_dps
317            )
318            .execute(state.dbs.get::<crate::cron::CronDb>())
319            .await?;
320
321            upserted += 1;
322        }
323    }
324
325    tracing::info!(
326        anchor = %anchor_day,
327        upserted,
328        "Rolling rollup completed"
329    );
330
331    Ok(())
332}