Skip to main content

headless_lms_models/
exercise_task_spec_files.rs

1//! The files an exercise task's specs reference.
2//!
3//! An exercise service declares them, the same way an answer names its files: the host stores
4//! specs as opaque blobs, so a declaration is the only way it can know that a stored file is still
5//! in use. The private spec's files are declared in the editor's `current-state` message, and each
6//! derived spec's in the response of the endpoint that produced it.
7//!
8//! The kinds are tracked apart because a derived spec can name a file the private spec never did:
9//! a service may upload while deriving, through `SpecRequest.upload_url`, which is how tmc stores
10//! the template students download.
11//!
12//! Used only to keep [`crate::exercise_spec_uploads`]' reaper off files that are still referenced.
13
14use crate::prelude::*;
15use std::collections::HashMap;
16
17/// Which of a task's three specs a reference belongs to.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
19#[sqlx(type_name = "exercise_spec_kind", rename_all = "snake_case")]
20#[serde(rename_all = "snake_case")]
21pub enum SpecKind {
22    Private,
23    Public,
24    ModelSolution,
25}
26
27/// Replaces what one of a task's specs declares with `file_upload_ids`.
28///
29/// Scoped to a single kind: storing a derived spec must not disturb what the private spec declares,
30/// and vice versa. Rewriting in full is what makes a dropped file reclaimable — the rows for files
31/// no longer named are soft-deleted, and once no history version names them either the upload
32/// becomes reapable. Re-declaring a file that is already recorded leaves its row alone, so a save
33/// that changes nothing does not churn the table.
34///
35/// Fails with `PreconditionFailed` on an id the host has no live upload for, or one the reaper has
36/// already reclaimed, naming it: the caller is relaying a list an exercise service sent, and a bad
37/// id is that service's bug, not a reason to surface a foreign key violation from a page save.
38pub async fn replace_for_exercise_task(
39    conn: &mut PgConnection,
40    exercise_task_id: Uuid,
41    spec_kind: SpecKind,
42    file_upload_ids: &[Uuid],
43) -> ModelResult<()> {
44    let mut tx = conn.begin().await?;
45    // Locks the recorded uploads for the rest of the transaction. Without it a reaper run can read
46    // the reference tables, find nothing, and retire an upload between the check below and the
47    // commit that references it; holding the lock makes it block here and re-read them after.
48    let recorded = sqlx::query!(
49        "
50SELECT file_upload_id,
51  deleted_at
52FROM exercise_spec_uploads
53WHERE file_upload_id = ANY($1)
54FOR UPDATE
55",
56        file_upload_ids
57    )
58    .fetch_all(&mut *tx)
59    .await?;
60    let reclaimed: Vec<Uuid> = recorded
61        .iter()
62        .filter(|upload| upload.deleted_at.is_some())
63        .map(|upload| upload.file_upload_id)
64        .collect();
65    if !reclaimed.is_empty() {
66        tx.rollback().await?;
67        return Err(model_err!(
68            PreconditionFailed,
69            format!(
70                "An exercise service declared files the reaper has already reclaimed: {}",
71                comma_separated(&reclaimed)
72            )
73        ));
74    }
75    let unknown: Vec<Uuid> = sqlx::query_scalar!(
76        "
77SELECT declared.file_upload_id
78FROM UNNEST($1::uuid []) AS declared(file_upload_id)
79WHERE NOT EXISTS (
80    SELECT 1
81    FROM file_uploads
82    WHERE id = declared.file_upload_id
83      AND deleted_at IS NULL
84  )
85",
86        file_upload_ids
87    )
88    .fetch_all(&mut *tx)
89    .await?
90    .into_iter()
91    .flatten()
92    .collect();
93    if !unknown.is_empty() {
94        tx.rollback().await?;
95        return Err(model_err!(
96            PreconditionFailed,
97            format!(
98                "An exercise service declared files the host has no upload for: {}",
99                comma_separated(&unknown)
100            )
101        ));
102    }
103    sqlx::query!(
104        "
105UPDATE exercise_task_spec_files
106SET deleted_at = now()
107WHERE exercise_task_id = $1
108  AND spec_kind = $3
109  AND deleted_at IS NULL
110  AND NOT file_upload_id = ANY($2)
111",
112        exercise_task_id,
113        file_upload_ids,
114        spec_kind as SpecKind
115    )
116    .execute(&mut *tx)
117    .await?;
118    sqlx::query!(
119        "
120INSERT INTO exercise_task_spec_files (exercise_task_id, file_upload_id, spec_kind)
121SELECT $1,
122  file_upload_id,
123  $3
124FROM UNNEST($2::uuid []) AS t(file_upload_id)
125WHERE NOT EXISTS (
126    SELECT 1
127    FROM exercise_task_spec_files AS existing
128    WHERE existing.exercise_task_id = $1
129      AND existing.file_upload_id = t.file_upload_id
130      AND existing.spec_kind = $3
131      AND existing.deleted_at IS NULL
132  )
133",
134        exercise_task_id,
135        file_upload_ids,
136        spec_kind as SpecKind
137    )
138    .execute(&mut *tx)
139    .await?;
140    tx.commit().await?;
141    Ok(())
142}
143
144fn comma_separated(ids: &[Uuid]) -> String {
145    ids.iter()
146        .map(|id| id.to_string())
147        .collect::<Vec<_>>()
148        .join(", ")
149}
150
151/// What each task's spec of the given kind declares, keyed by task. Tasks that declare nothing are
152/// absent from the map rather than present with an empty list.
153pub async fn get_by_exercise_task_ids(
154    conn: &mut PgConnection,
155    exercise_task_ids: &[Uuid],
156    spec_kind: SpecKind,
157) -> ModelResult<HashMap<Uuid, Vec<Uuid>>> {
158    let rows = sqlx::query!(
159        "
160SELECT exercise_task_id,
161  file_upload_id
162FROM exercise_task_spec_files
163WHERE exercise_task_id = ANY($1)
164  AND spec_kind = $2
165  AND deleted_at IS NULL
166",
167        exercise_task_ids,
168        spec_kind as SpecKind
169    )
170    .fetch_all(conn)
171    .await?;
172    let mut by_task: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
173    for row in rows {
174        by_task
175            .entry(row.exercise_task_id)
176            .or_default()
177            .push(row.file_upload_id);
178    }
179    Ok(by_task)
180}
181
182/// What one of a task's specs declares, in no particular order: the order of a spec's files is the
183/// exercise service's business and lives inside the spec.
184pub async fn get_for_exercise_task(
185    conn: &mut PgConnection,
186    exercise_task_id: Uuid,
187    spec_kind: SpecKind,
188) -> ModelResult<Vec<Uuid>> {
189    let res = sqlx::query_scalar!(
190        "
191SELECT file_upload_id
192FROM exercise_task_spec_files
193WHERE exercise_task_id = $1
194  AND spec_kind = $2
195  AND deleted_at IS NULL
196",
197        exercise_task_id,
198        spec_kind as SpecKind
199    )
200    .fetch_all(conn)
201    .await?;
202    Ok(res)
203}
204
205/// Counts a task's rows across all spec kinds, soft-deleted ones included, which is what tells
206/// churn from a no-op.
207pub async fn count_for_exercise_task_including_deleted(
208    conn: &mut PgConnection,
209    exercise_task_id: Uuid,
210) -> ModelResult<i64> {
211    let count = sqlx::query_scalar!(
212        r#"
213SELECT COUNT(*) AS "count!"
214FROM exercise_task_spec_files
215WHERE exercise_task_id = $1
216        "#,
217        exercise_task_id
218    )
219    .fetch_one(conn)
220    .await?;
221    Ok(count)
222}
223
224#[cfg(test)]
225mod test {
226    use super::*;
227    use crate::test_helper::*;
228
229    async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
230        crate::file_uploads::insert(
231            tx,
232            name,
233            &format!("tmc/{name}"),
234            "application/octet-stream",
235            None,
236            None,
237        )
238        .await
239        .unwrap()
240    }
241
242    #[tokio::test]
243    async fn a_save_that_drops_a_file_releases_only_that_one() {
244        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
245        let kept = insert_file(tx.as_mut(), "kept").await;
246        let dropped = insert_file(tx.as_mut(), "dropped").await;
247        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept, dropped])
248            .await
249            .unwrap();
250
251        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept])
252            .await
253            .unwrap();
254
255        let declared = get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
256            .await
257            .unwrap();
258        assert_eq!(declared, vec![kept]);
259    }
260
261    /// The kinds have to be independent: storing a derived spec must not release what the private
262    /// spec declares, and a derived spec may name a file the private spec never did.
263    #[tokio::test]
264    async fn replacing_one_kind_leaves_the_others_alone() {
265        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
266        let in_private = insert_file(tx.as_mut(), "teacher-example").await;
267        let in_public = insert_file(tx.as_mut(), "stub-archive").await;
268        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[in_private])
269            .await
270            .unwrap();
271        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[in_public])
272            .await
273            .unwrap();
274
275        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[])
276            .await
277            .unwrap();
278
279        assert_eq!(
280            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
281                .await
282                .unwrap(),
283            vec![in_private]
284        );
285        assert!(
286            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public)
287                .await
288                .unwrap()
289                .is_empty()
290        );
291    }
292
293    /// Re-declaring must not churn: the editor emits a spec on every keystroke, so a save that
294    /// changes nothing would otherwise soft-delete and re-insert every row each time.
295    #[tokio::test]
296    async fn re_declaring_the_same_file_keeps_its_row() {
297        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
298        let file_id = insert_file(tx.as_mut(), "unchanged").await;
299        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
300            .await
301            .unwrap();
302        let first = row_count(tx.as_mut(), task_id).await;
303
304        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
305            .await
306            .unwrap();
307
308        assert_eq!(row_count(tx.as_mut(), task_id).await, first);
309        assert_eq!(
310            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
311                .await
312                .unwrap(),
313            vec![file_id]
314        );
315    }
316
317    #[tokio::test]
318    async fn refuses_a_file_the_host_has_no_upload_for() {
319        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
320        let known = insert_file(tx.as_mut(), "known").await;
321
322        replace_for_exercise_task(
323            tx.as_mut(),
324            task_id,
325            SpecKind::Private,
326            &[known, Uuid::new_v4()],
327        )
328        .await
329        .expect_err("an unknown id is the exercise service's bug, reported as such");
330
331        assert!(
332            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
333                .await
334                .unwrap()
335                .is_empty()
336        );
337    }
338
339    /// The reaper retires the upload before it removes the object, so a declaration that lands in
340    /// that window would reference a file that is about to disappear.
341    #[tokio::test]
342    async fn refuses_a_file_the_reaper_has_already_reclaimed() {
343        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
344        let file_id = insert_file(tx.as_mut(), "reclaimed").await;
345        crate::exercise_spec_uploads::insert_many(tx.as_mut(), "tmc", None, &[file_id])
346            .await
347            .unwrap();
348        let recorded = crate::exercise_spec_uploads::get_by_file_upload_id(tx.as_mut(), file_id)
349            .await
350            .unwrap()
351            .expect("recorded");
352        assert!(
353            crate::exercise_spec_uploads::mark_reaped(tx.as_mut(), recorded.id)
354                .await
355                .unwrap()
356        );
357
358        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
359            .await
360            .expect_err("the file's object is already on its way out");
361
362        assert!(
363            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
364                .await
365                .unwrap()
366                .is_empty()
367        );
368    }
369
370    #[tokio::test]
371    async fn groups_declarations_by_task() {
372        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
373        let file_id = insert_file(tx.as_mut(), "declared").await;
374        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
375            .await
376            .unwrap();
377
378        let by_task = get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Private)
379            .await
380            .unwrap();
381
382        assert_eq!(by_task.get(&task_id), Some(&vec![file_id]));
383        assert!(
384            get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Public)
385                .await
386                .unwrap()
387                .is_empty()
388        );
389    }
390
391    async fn row_count(conn: &mut PgConnection, exercise_task_id: Uuid) -> i64 {
392        count_for_exercise_task_including_deleted(conn, exercise_task_id)
393            .await
394            .unwrap()
395    }
396}