Skip to main content

headless_lms_models/
exercise_spec_uploads.rs

1//! Files uploaded through the exercise-service upload route, which is how a teacher's CMS editor
2//! and the playground store files.
3//!
4//! These are recorded so that abandoned ones can be reclaimed. Nothing else can: the host never
5//! reads a spec blob, so a stored file's only reference may live inside content the host cannot
6//! parse. The counterpart is [`crate::exercise_task_spec_files`], where an exercise service
7//! declares which files its spec actually names.
8
9use crate::prelude::*;
10use chrono::Duration;
11
12/// Cap on one reaper run's listing, bounding its object-store fan-out and its runtime under the
13/// CronJob deadline. A backlog is worked off over successive runs.
14const REAP_BATCH_LIMIT: i64 = 1000;
15
16/// Records freshly uploaded files so the reaper can later tell an abandoned one from a file it
17/// knows nothing about.
18pub async fn insert_many(
19    conn: &mut PgConnection,
20    exercise_service_slug: &str,
21    uploaded_by_user: Option<Uuid>,
22    file_upload_ids: &[Uuid],
23) -> ModelResult<()> {
24    sqlx::query!(
25        "
26INSERT INTO exercise_spec_uploads (file_upload_id, exercise_service_slug, uploaded_by_user)
27SELECT file_upload_id,
28  $2,
29  $3
30FROM UNNEST($1::uuid []) AS t(file_upload_id)
31",
32        file_upload_ids,
33        exercise_service_slug,
34        uploaded_by_user
35    )
36    .execute(conn)
37    .await?;
38    Ok(())
39}
40
41/// An upload the reaper may remove: old enough, and named by no spec anywhere.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ReapableUpload {
44    pub id: Uuid,
45    pub file_upload_id: Uuid,
46    /// Object-store path of the file to remove.
47    pub path: String,
48}
49
50/// Uploads older than seven days that no live spec and no page-history version references, oldest
51/// first, at most [`REAP_BATCH_LIMIT`] of them.
52///
53/// Seven days is generous on purpose: the window has to cover the gap between uploading a file and
54/// saving the page that references it, which is a teacher's editing session and can span days.
55///
56/// `FROM exercise_spec_uploads` is the safety property of this whole feature, not an optimisation:
57/// `file_uploads` also holds CMS media, organization images, certificates and answer files, none of
58/// which are recorded here, so the host cannot tell whether one is still needed. Widening this
59/// query to `file_uploads` would silently destroy course media. Never do it.
60///
61/// The `declares_spec_files` gate is the second half of that safety. A service that does not
62/// declare what its specs reference gives the host no evidence at all that a file is unused, so
63/// none of its uploads are ever considered — the flag exists precisely so services written before
64/// the declarations do not lose files. The playground is exempt because it has no specs and no
65/// exercise service behind its reserved slug: what it uploads is throwaway by construction.
66///
67/// Both reference tables are consulted, and the history one is why this reaper collects less than
68/// it looks like it should: a restore has to be able to bring back a version whose specs name a
69/// file, so a file that ever reached a saved spec stays out of reach for as long as its history is
70/// kept. What is left to collect is uploads that never made it into a save — a file the teacher
71/// replaced before saving, or an editing session that was closed.
72///
73/// Progress is tracked by `file_uploads.deleted_at`, not by this table's: the upload is retired
74/// first and the object removed afterwards, so a row whose object delete failed still has a live
75/// `file_uploads` row and comes back on the next run. Filtering on `u.deleted_at IS NULL` instead
76/// would make every transient object-store error orphan its object permanently.
77pub async fn get_reapable(conn: &mut PgConnection) -> ModelResult<Vec<ReapableUpload>> {
78    let res = sqlx::query_as!(
79        ReapableUpload,
80        "
81SELECT u.id,
82  u.file_upload_id,
83  f.path
84FROM exercise_spec_uploads AS u
85  JOIN file_uploads AS f ON f.id = u.file_upload_id
86WHERE f.deleted_at IS NULL
87  AND u.created_at < now() - interval '7 days'
88  AND (
89    u.exercise_service_slug = 'playground'
90    OR EXISTS (
91      SELECT 1
92      FROM exercise_services AS s
93        JOIN exercise_service_info AS i ON i.exercise_service_id = s.id
94      WHERE s.slug = u.exercise_service_slug
95        AND s.deleted_at IS NULL
96        AND i.declares_spec_files
97    )
98  )
99  AND NOT EXISTS (
100    SELECT 1
101    FROM exercise_task_spec_files AS t
102    WHERE t.file_upload_id = u.file_upload_id
103      AND t.deleted_at IS NULL
104  )
105  AND NOT EXISTS (
106    SELECT 1
107    FROM page_history_spec_files AS h
108    WHERE h.file_upload_id = u.file_upload_id
109      AND h.deleted_at IS NULL
110  )
111ORDER BY u.created_at
112LIMIT $1
113",
114        REAP_BATCH_LIMIT
115    )
116    .fetch_all(conn)
117    .await?;
118    Ok(res)
119}
120
121/// Retires an upload. Idempotent, so a run retrying a failed object delete can call it again.
122///
123/// Re-checks the two reference tables under a row lock, and reports `false` if a save has come to
124/// reference the upload since `get_reapable` listed it. The lock is taken in a statement of its own
125/// because under READ COMMITTED a statement's snapshot is fixed when it starts and Postgres
126/// refreshes only the row an `UPDATE` locks, never the rows its subqueries read: a single locking
127/// `UPDATE` would block on a concurrent save, unblock, and then evaluate `NOT EXISTS` against a
128/// snapshot from before that save committed.
129pub async fn mark_reaped(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
130    let mut tx = conn.begin().await?;
131    let locked = sqlx::query_scalar!(
132        "
133SELECT id
134FROM exercise_spec_uploads
135WHERE id = $1
136FOR UPDATE
137",
138        id
139    )
140    .fetch_optional(&mut *tx)
141    .await?;
142    if locked.is_none() {
143        tx.rollback().await?;
144        return Ok(false);
145    }
146    let retired = sqlx::query_scalar!(
147        "
148UPDATE exercise_spec_uploads AS u
149SET deleted_at = COALESCE(u.deleted_at, now())
150WHERE u.id = $1
151  AND NOT EXISTS (
152    SELECT 1
153    FROM exercise_task_spec_files AS t
154    WHERE t.file_upload_id = u.file_upload_id
155      AND t.deleted_at IS NULL
156  )
157  AND NOT EXISTS (
158    SELECT 1
159    FROM page_history_spec_files AS h
160    WHERE h.file_upload_id = u.file_upload_id
161      AND h.deleted_at IS NULL
162  )
163RETURNING u.id
164",
165        id
166    )
167    .fetch_optional(&mut *tx)
168    .await?;
169    tx.commit().await?;
170    Ok(retired.is_some())
171}
172
173/// The recorded upload for a file, if any. `deleted` marks one the reaper has retired.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct SpecUpload {
176    pub id: Uuid,
177    pub file_upload_id: Uuid,
178    pub deleted: bool,
179}
180
181pub async fn get_by_file_upload_id(
182    conn: &mut PgConnection,
183    file_upload_id: Uuid,
184) -> ModelResult<Option<SpecUpload>> {
185    let res = sqlx::query!(
186        "
187SELECT id,
188  file_upload_id,
189  deleted_at
190FROM exercise_spec_uploads
191WHERE file_upload_id = $1
192",
193        file_upload_id
194    )
195    .fetch_optional(conn)
196    .await?;
197    Ok(res.map(|row| SpecUpload {
198        id: row.id,
199        file_upload_id: row.file_upload_id,
200        deleted: row.deleted_at.is_some(),
201    }))
202}
203
204/// Ages a recorded upload, so a test can reach the retention window without waiting a week.
205pub async fn backdate(
206    conn: &mut PgConnection,
207    file_upload_id: Uuid,
208    age: Duration,
209) -> ModelResult<()> {
210    sqlx::query!(
211        "
212UPDATE exercise_spec_uploads
213SET created_at = now() - $2::interval
214WHERE file_upload_id = $1
215",
216        file_upload_id,
217        age as Duration
218    )
219    .execute(conn)
220    .await?;
221    Ok(())
222}
223
224#[cfg(test)]
225mod test {
226    use super::*;
227    use crate::exercise_task_spec_files::SpecKind;
228    use crate::test_helper::*;
229
230    const DECLARING_SLUG: &str = "declaring-service";
231    const SILENT_SLUG: &str = "silent-service";
232
233    async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
234        crate::file_uploads::insert(
235            tx,
236            name,
237            &format!("{DECLARING_SLUG}/{name}"),
238            "application/octet-stream",
239            None,
240            None,
241        )
242        .await
243        .unwrap()
244    }
245
246    /// A service and its info row, since the reaper only considers uploads of a service that
247    /// declares what its specs reference.
248    async fn insert_service(tx: &mut PgConnection, slug: &str, declares_spec_files: bool) {
249        let service = crate::exercise_services::insert_exercise_service(
250            tx,
251            &crate::exercise_services::ExerciseServiceNewOrUpdate {
252                name: slug.to_string(),
253                slug: slug.to_string(),
254                public_url: format!("http://{slug}.example.com/api/service-info"),
255                internal_url: None,
256                max_reprocessing_submissions_at_once: 1,
257                is_global_admin_only: false,
258                is_experimental: false,
259            },
260        )
261        .await
262        .unwrap();
263        crate::exercise_service_info::insert(
264            tx,
265            &crate::exercise_service_info::PathInfo {
266                exercise_service_id: service.id,
267                user_interface_iframe_path: "/iframe".to_string(),
268                grade_endpoint_path: "/api/grade".to_string(),
269                public_spec_endpoint_path: "/api/public-spec".to_string(),
270                model_solution_spec_endpoint_path: "/api/model-solution".to_string(),
271                has_custom_view: false,
272                supports_native_client: false,
273                produces_file_answers: false,
274                declares_spec_files,
275            },
276        )
277        .await
278        .unwrap();
279    }
280
281    /// Records an abandoned-looking upload: old enough, declared by nothing.
282    async fn insert_stale_upload(tx: &mut PgConnection, slug: &str, name: &str) -> Uuid {
283        let file_id = insert_file(&mut *tx, name).await;
284        insert_many(&mut *tx, slug, None, &[file_id]).await.unwrap();
285        backdate(&mut *tx, file_id, Duration::days(8))
286            .await
287            .unwrap();
288        file_id
289    }
290
291    fn lists(reapable: &[ReapableUpload], file_upload_id: Uuid) -> bool {
292        reapable
293            .iter()
294            .any(|upload| upload.file_upload_id == file_upload_id)
295    }
296
297    #[tokio::test]
298    async fn lists_an_upload_no_spec_declares() {
299        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:_task);
300        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
301        let file_id = insert_stale_upload(tx.as_mut(), DECLARING_SLUG, "abandoned").await;
302
303        let reapable = get_reapable(tx.as_mut()).await.unwrap();
304
305        assert!(lists(&reapable, file_id));
306    }
307
308    #[tokio::test]
309    async fn spares_an_upload_inside_the_retention_window() {
310        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:_task);
311        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
312        let file_id = insert_file(tx.as_mut(), "fresh").await;
313        insert_many(tx.as_mut(), DECLARING_SLUG, None, &[file_id])
314            .await
315            .unwrap();
316
317        let reapable = get_reapable(tx.as_mut()).await.unwrap();
318
319        assert!(!lists(&reapable, file_id));
320    }
321
322    /// The gate that protects every service written before the declarations existed: without a
323    /// declaration the host has no evidence a file is unused, so it must keep it.
324    #[tokio::test]
325    async fn never_lists_an_upload_of_a_service_that_declares_nothing() {
326        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:_task);
327        insert_service(tx.as_mut(), SILENT_SLUG, false).await;
328        let file_id = insert_stale_upload(tx.as_mut(), SILENT_SLUG, "kept-forever").await;
329
330        let reapable = get_reapable(tx.as_mut()).await.unwrap();
331
332        assert!(!lists(&reapable, file_id));
333    }
334
335    /// The playground has no specs and no service behind its reserved slug, so what it stores is
336    /// throwaway and reapable without any declaration.
337    #[tokio::test]
338    async fn lists_a_playground_upload_although_no_service_declares() {
339        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:_task);
340        let file_id = insert_stale_upload(tx.as_mut(), "playground", "playground-file").await;
341
342        let reapable = get_reapable(tx.as_mut()).await.unwrap();
343
344        assert!(lists(&reapable, file_id));
345    }
346
347    #[tokio::test]
348    async fn spares_an_upload_a_live_spec_declares() {
349        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
350        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
351        let file_id = insert_stale_upload(tx.as_mut(), DECLARING_SLUG, "in-a-spec").await;
352        crate::exercise_task_spec_files::replace_for_exercise_task(
353            tx.as_mut(),
354            task_id,
355            SpecKind::Private,
356            &[file_id],
357        )
358        .await
359        .unwrap();
360
361        let reapable = get_reapable(tx.as_mut()).await.unwrap();
362
363        assert!(!lists(&reapable, file_id));
364    }
365
366    /// A derived spec's declaration counts too. It is the only one that can: a file uploaded while
367    /// deriving is named by no private spec, so checking one kind would delete tmc's stub archives.
368    #[tokio::test]
369    async fn spares_an_upload_only_a_derived_spec_declares() {
370        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
371        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
372        let file_id = insert_stale_upload(tx.as_mut(), DECLARING_SLUG, "stub-archive").await;
373        crate::exercise_task_spec_files::replace_for_exercise_task(
374            tx.as_mut(),
375            task_id,
376            SpecKind::Public,
377            &[file_id],
378        )
379        .await
380        .unwrap();
381
382        let reapable = get_reapable(tx.as_mut()).await.unwrap();
383
384        assert!(!lists(&reapable, file_id));
385    }
386
387    /// A file dropped from the current spec but still named by a snapshot a restore could bring
388    /// back. This is what makes the reaper collect abandoned uploads rather than dropped files.
389    #[tokio::test]
390    async fn spares_an_upload_only_page_history_declares() {
391        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:page_id, exercise:_exercise, slide:_slide, task:_task);
392        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
393        let file_id =
394            insert_stale_upload(tx.as_mut(), DECLARING_SLUG, "dropped-but-in-history").await;
395        let history_id = crate::page_history::insert(
396            tx.as_mut(),
397            PKeyPolicy::Generate,
398            page_id,
399            "Snapshot",
400            &crate::page_history::PageHistoryContent {
401                content: serde_json::json!([]),
402                exercises: vec![],
403                exercise_slides: vec![],
404                exercise_tasks: vec![],
405                peer_or_self_review_configs: vec![],
406                peer_or_self_review_questions: vec![],
407            },
408            crate::page_history::HistoryChangeReason::PageSaved,
409            crate::entity_history::HistoryAuthor::user(user_id),
410            None,
411        )
412        .await
413        .unwrap();
414        crate::page_history_spec_files::insert_many(tx.as_mut(), history_id, &[file_id])
415            .await
416            .unwrap();
417
418        let reapable = get_reapable(tx.as_mut()).await.unwrap();
419
420        assert!(!lists(&reapable, file_id));
421    }
422
423    /// The re-check under the row lock, which is what stops a reap landing between `get_reapable`
424    /// and the delete of a file a save has since declared.
425    #[tokio::test]
426    async fn declines_to_retire_an_upload_declared_after_it_was_listed() {
427        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:task_id);
428        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
429        let file_id = insert_stale_upload(tx.as_mut(), DECLARING_SLUG, "declared-late").await;
430        let listed = get_reapable(tx.as_mut()).await.unwrap();
431        let upload = listed
432            .iter()
433            .find(|upload| upload.file_upload_id == file_id)
434            .expect("listed");
435        crate::exercise_task_spec_files::replace_for_exercise_task(
436            tx.as_mut(),
437            task_id,
438            SpecKind::Private,
439            &[file_id],
440        )
441        .await
442        .unwrap();
443
444        assert!(!mark_reaped(tx.as_mut(), upload.id).await.unwrap());
445        assert_eq!(
446            get_by_file_upload_id(tx.as_mut(), file_id)
447                .await
448                .unwrap()
449                .map(|recorded| recorded.deleted),
450            Some(false)
451        );
452    }
453
454    #[tokio::test]
455    async fn retires_an_upload_nothing_declares() {
456        insert_data!(:tx, user:_user, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:_exercise, slide:_slide, task:_task);
457        insert_service(tx.as_mut(), DECLARING_SLUG, true).await;
458        let file_id = insert_stale_upload(tx.as_mut(), DECLARING_SLUG, "retired").await;
459        let recorded = get_by_file_upload_id(tx.as_mut(), file_id)
460            .await
461            .unwrap()
462            .expect("recorded");
463
464        assert!(mark_reaped(tx.as_mut(), recorded.id).await.unwrap());
465
466        assert_eq!(
467            get_by_file_upload_id(tx.as_mut(), file_id)
468                .await
469                .unwrap()
470                .map(|recorded| recorded.deleted),
471            Some(true),
472            "the row survives soft-deleted, as the audit trail of what was reclaimed"
473        );
474    }
475}