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#[cfg(test)]
206mod test {
207    use super::*;
208    use crate::test_helper::*;
209
210    async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
211        crate::file_uploads::insert(
212            tx,
213            name,
214            &format!("tmc/{name}"),
215            "application/octet-stream",
216            None,
217            None,
218        )
219        .await
220        .unwrap()
221    }
222
223    #[tokio::test]
224    async fn a_save_that_drops_a_file_releases_only_that_one() {
225        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
226        let kept = insert_file(tx.as_mut(), "kept").await;
227        let dropped = insert_file(tx.as_mut(), "dropped").await;
228        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept, dropped])
229            .await
230            .unwrap();
231
232        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[kept])
233            .await
234            .unwrap();
235
236        let declared = get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
237            .await
238            .unwrap();
239        assert_eq!(declared, vec![kept]);
240    }
241
242    /// The kinds have to be independent: storing a derived spec must not release what the private
243    /// spec declares, and a derived spec may name a file the private spec never did.
244    #[tokio::test]
245    async fn replacing_one_kind_leaves_the_others_alone() {
246        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
247        let in_private = insert_file(tx.as_mut(), "teacher-example").await;
248        let in_public = insert_file(tx.as_mut(), "stub-archive").await;
249        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[in_private])
250            .await
251            .unwrap();
252        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[in_public])
253            .await
254            .unwrap();
255
256        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public, &[])
257            .await
258            .unwrap();
259
260        assert_eq!(
261            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
262                .await
263                .unwrap(),
264            vec![in_private]
265        );
266        assert!(
267            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Public)
268                .await
269                .unwrap()
270                .is_empty()
271        );
272    }
273
274    /// Re-declaring must not churn: the editor emits a spec on every keystroke, so a save that
275    /// changes nothing would otherwise soft-delete and re-insert every row each time.
276    #[tokio::test]
277    async fn re_declaring_the_same_file_keeps_its_row() {
278        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
279        let file_id = insert_file(tx.as_mut(), "unchanged").await;
280        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
281            .await
282            .unwrap();
283        let first = row_count(tx.as_mut(), task_id).await;
284
285        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
286            .await
287            .unwrap();
288
289        assert_eq!(row_count(tx.as_mut(), task_id).await, first);
290        assert_eq!(
291            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
292                .await
293                .unwrap(),
294            vec![file_id]
295        );
296    }
297
298    #[tokio::test]
299    async fn refuses_a_file_the_host_has_no_upload_for() {
300        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
301        let known = insert_file(tx.as_mut(), "known").await;
302
303        replace_for_exercise_task(
304            tx.as_mut(),
305            task_id,
306            SpecKind::Private,
307            &[known, Uuid::new_v4()],
308        )
309        .await
310        .expect_err("an unknown id is the exercise service's bug, reported as such");
311
312        assert!(
313            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
314                .await
315                .unwrap()
316                .is_empty()
317        );
318    }
319
320    /// The reaper retires the upload before it removes the object, so a declaration that lands in
321    /// that window would reference a file that is about to disappear.
322    #[tokio::test]
323    async fn refuses_a_file_the_reaper_has_already_reclaimed() {
324        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
325        let file_id = insert_file(tx.as_mut(), "reclaimed").await;
326        crate::exercise_spec_uploads::insert_many(tx.as_mut(), "tmc", None, &[file_id])
327            .await
328            .unwrap();
329        let recorded = crate::exercise_spec_uploads::get_by_file_upload_id(tx.as_mut(), file_id)
330            .await
331            .unwrap()
332            .expect("recorded");
333        assert!(
334            crate::exercise_spec_uploads::mark_reaped(tx.as_mut(), recorded.id)
335                .await
336                .unwrap()
337        );
338
339        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
340            .await
341            .expect_err("the file's object is already on its way out");
342
343        assert!(
344            get_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private)
345                .await
346                .unwrap()
347                .is_empty()
348        );
349    }
350
351    #[tokio::test]
352    async fn groups_declarations_by_task() {
353        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
354        let file_id = insert_file(tx.as_mut(), "declared").await;
355        replace_for_exercise_task(tx.as_mut(), task_id, SpecKind::Private, &[file_id])
356            .await
357            .unwrap();
358
359        let by_task = get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Private)
360            .await
361            .unwrap();
362
363        assert_eq!(by_task.get(&task_id), Some(&vec![file_id]));
364        assert!(
365            get_by_exercise_task_ids(tx.as_mut(), &[task_id], SpecKind::Public)
366                .await
367                .unwrap()
368                .is_empty()
369        );
370    }
371
372    /// Counts every row, soft-deleted ones included, which is what tells churn from a no-op.
373    ///
374    /// Not a `query_scalar!`: `cargo sqlx prepare -- --lib` does not cache test-only queries.
375    async fn row_count(conn: &mut PgConnection, exercise_task_id: Uuid) -> i64 {
376        sqlx::query_scalar(
377            "SELECT COUNT(*) FROM exercise_task_spec_files WHERE exercise_task_id = $1",
378        )
379        .bind(exercise_task_id)
380        .fetch_one(conn)
381        .await
382        .unwrap()
383    }
384}