Skip to main content

headless_lms_models/library/credit_registration/
course_unit_poll_slots.rs

1//! When a partially registered row next looks for its course unit attainment. Sisu creates those in
2//! a nightly batch, so a row polls once after it, on the morning slot, and again on the noon slot if
3//! the morning poll came too early. A row that first shows its assessment item attainment during the
4//! day waits for the next morning.
5
6use chrono::TimeDelta;
7use headless_lms_utils::backoff::next_attempt_at;
8
9use crate::prelude::*;
10
11/// Finnish wall-clock time of the daily poll, after Sisu's nightly course unit batch.
12const MORNING_SLOT: TimeDelta = TimeDelta::minutes(7 * 60 + 15);
13/// Finnish wall-clock time of the catch-up poll for a row the morning poll missed.
14const CATCH_UP_SLOT: TimeDelta = TimeDelta::hours(12);
15/// How long after its slot a poll may land, so the rows of one slot do not reach Suotar at once.
16pub const SLOT_SPREAD: TimeDelta = TimeDelta::minutes(30);
17
18/// The next course unit poll of each row, spread over the [`SLOT_SPREAD`] after its slot, in the
19/// order of `first_seen_at`: when a poll first saw the row's assessment item attainment, or `now`
20/// for a row whose poll may see it now.
21pub async fn next_course_unit_polls(
22    conn: &mut PgConnection,
23    now: DateTime<Utc>,
24    first_seen_at: &[DateTime<Utc>],
25) -> ModelResult<Vec<DateTime<Utc>>> {
26    Ok(next_slots(conn, now, first_seen_at)
27        .await?
28        .into_iter()
29        .map(|slot| next_attempt_at(slot, 0, SLOT_SPREAD.num_seconds()))
30        .collect())
31}
32
33/// Read off the database's time zone data, which knows when Finland changes to and from summer
34/// time. Neither slot falls in the hour a change skips or repeats.
35async fn next_slots(
36    conn: &mut PgConnection,
37    now: DateTime<Utc>,
38    first_seen_at: &[DateTime<Utc>],
39) -> ModelResult<Vec<DateTime<Utc>>> {
40    let slots = sqlx::query_scalar!(
41        r#"
42SELECT (
43    CASE
44      WHEN slot.local_now < slot.morning THEN slot.morning
45      WHEN slot.local_now < slot.catch_up
46      AND slot.local_first_seen < slot.morning THEN slot.catch_up
47      ELSE slot.morning + INTERVAL '1 day'
48    END
49  ) AT TIME ZONE 'Europe/Helsinki' AS "slot!: DateTime<Utc>"
50FROM UNNEST($2::timestamptz []) WITH ORDINALITY AS seen(first_seen_at, position)
51  CROSS JOIN LATERAL (
52    SELECT $1::timestamptz AT TIME ZONE 'Europe/Helsinki' AS local_now,
53      seen.first_seen_at AT TIME ZONE 'Europe/Helsinki' AS local_first_seen,
54      ($1::timestamptz AT TIME ZONE 'Europe/Helsinki')::date + $3::interval AS morning,
55      ($1::timestamptz AT TIME ZONE 'Europe/Helsinki')::date + $4::interval AS catch_up
56  ) slot
57ORDER BY seen.position
58        "#,
59        now,
60        first_seen_at,
61        MORNING_SLOT as TimeDelta,
62        CATCH_UP_SLOT as TimeDelta,
63    )
64    .fetch_all(conn)
65    .await?;
66    Ok(slots)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::test_helper::*;
73
74    fn utc(at: &str) -> DateTime<Utc> {
75        DateTime::parse_from_rfc3339(at).unwrap().to_utc()
76    }
77
78    async fn slot(conn: &mut PgConnection, now: &str, first_seen_at: &str) -> DateTime<Utc> {
79        next_slots(conn, utc(now), &[utc(first_seen_at)])
80            .await
81            .unwrap()[0]
82    }
83
84    #[tokio::test]
85    async fn a_row_seen_overnight_polls_the_same_morning_and_then_at_noon() {
86        let mut conn = Conn::init().await;
87        let mut tx = conn.begin().await;
88        let seen = "2027-01-12T03:30:00+02:00";
89        assert_eq!(
90            slot(tx.as_mut(), seen, seen).await,
91            utc("2027-01-12T07:15:00+02:00")
92        );
93        assert_eq!(
94            slot(tx.as_mut(), "2027-01-12T07:40:00+02:00", seen).await,
95            utc("2027-01-12T12:00:00+02:00")
96        );
97        assert_eq!(
98            slot(tx.as_mut(), "2027-01-12T12:20:00+02:00", seen).await,
99            utc("2027-01-13T07:15:00+02:00")
100        );
101    }
102
103    #[tokio::test]
104    async fn a_row_seen_during_the_day_waits_for_the_next_morning() {
105        let mut conn = Conn::init().await;
106        let mut tx = conn.begin().await;
107        let seen = "2026-08-10T09:00:00+03:00";
108        assert_eq!(
109            slot(tx.as_mut(), seen, seen).await,
110            utc("2026-08-11T07:15:00+03:00")
111        );
112        let evening = "2026-08-10T22:00:00+03:00";
113        assert_eq!(
114            slot(tx.as_mut(), evening, evening).await,
115            utc("2026-08-11T07:15:00+03:00")
116        );
117    }
118
119    #[tokio::test]
120    async fn slots_keep_finnish_wall_clock_time_across_summer_time_changes() {
121        let mut conn = Conn::init().await;
122        let mut tx = conn.begin().await;
123        // Summer time ends on the night to 2026-10-25.
124        assert_eq!(
125            slot(
126                tx.as_mut(),
127                "2026-10-24T12:30:00+03:00",
128                "2026-10-24T03:00:00+03:00"
129            )
130            .await,
131            utc("2026-10-25T07:15:00+02:00")
132        );
133        let in_the_repeated_hour = "2026-10-25T03:30:00+03:00";
134        assert_eq!(
135            slot(tx.as_mut(), in_the_repeated_hour, in_the_repeated_hour).await,
136            utc("2026-10-25T07:15:00+02:00")
137        );
138        // Summer time starts on the night to 2027-03-28.
139        assert_eq!(
140            slot(
141                tx.as_mut(),
142                "2027-03-27T13:00:00+02:00",
143                "2027-03-27T13:00:00+02:00"
144            )
145            .await,
146            utc("2027-03-28T07:15:00+03:00")
147        );
148        assert_eq!(
149            slot(
150                tx.as_mut(),
151                "2027-03-28T07:30:00+03:00",
152                "2027-03-28T02:30:00+02:00"
153            )
154            .await,
155            utc("2027-03-28T12:00:00+03:00")
156        );
157    }
158}