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 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 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}