1use std::path::Path;
10
11use crate::config::{FileStoreRuntimeConfig, program_config::ProgramConfig};
12use crate::{setup_file_store, setup_tracing};
13use dotenvy::dotenv;
14use futures::{StreamExt, stream};
15use headless_lms_models::{self as models, error::TryToOptional};
16use headless_lms_utils::file_store::FileStore;
17use sqlx::{PgConnection, PgPool};
18
19const MAX_CONCURRENT_REAPS: usize = 8;
20
21pub async fn main() -> anyhow::Result<()> {
22 dotenv().ok();
23 ProgramConfig::ensure_default_rust_log_for_workers();
24 setup_tracing()?;
25 let database_url = ProgramConfig::database_url_with_default();
26 let base_url = ProgramConfig::required("BASE_URL")?;
27 let file_store = setup_file_store(&FileStoreRuntimeConfig::try_from_env()?, &base_url).await;
28 let db_pool = PgPool::connect(&database_url).await?;
29 reap(&db_pool, file_store.as_ref()).await
30}
31
32async fn reap(pool: &PgPool, file_store: &dyn FileStore) -> anyhow::Result<()> {
33 let mut conn = pool.acquire().await?;
34 let reapable = models::exercise_answer_uploads::get_reapable(&mut conn).await?;
35 drop(conn);
36 info!("Reaping {} orphaned answer uploads.", reapable.len());
37
38 let mut reaped = 0;
39 let mut skipped = 0;
40 let mut failed = 0;
41 let mut results = stream::iter(reapable)
42 .map(|upload| async move {
43 let file_upload_id = upload.file_upload_id;
44 let result = match pool.acquire().await {
45 Ok(mut conn) => reap_one(&mut conn, file_store, &upload).await,
46 Err(err) => Err(err.into()),
47 };
48 (file_upload_id, result)
49 })
50 .buffer_unordered(MAX_CONCURRENT_REAPS);
51 while let Some((file_upload_id, result)) = results.next().await {
52 match result {
53 Ok(true) => reaped += 1,
54 Ok(false) => skipped += 1,
55 Err(err) => {
56 failed += 1;
57 error!(
58 "Failed to reap answer upload {}: {:#?}",
59 file_upload_id, err
60 );
61 }
62 }
63 }
64 info!(
65 "Orphaned answer uploads reaped. Succeeded: {reaped}, skipped: {skipped}, failed: {failed}."
66 );
67 if failed > 0 {
70 anyhow::bail!(
71 "Failed to reap {failed} of {} answer uploads.",
72 reaped + failed
73 );
74 }
75 Ok(())
76}
77
78async fn reap_one(
87 conn: &mut PgConnection,
88 file_store: &dyn FileStore,
89 upload: &models::exercise_answer_uploads::ReapableUpload,
90) -> anyhow::Result<bool> {
91 if !models::exercise_answer_uploads::mark_reaped(conn, upload.id).await? {
92 return Ok(false);
93 }
94 file_store.delete(Path::new(&upload.path)).await?;
95 models::file_uploads::delete_and_fetch_path(conn, upload.file_upload_id)
98 .await
99 .optional()?;
100 Ok(true)
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::test_helper::*;
107 use chrono::Duration;
108 use headless_lms_base::error::backend_error::BackendError;
109 use headless_lms_utils::prelude::{UtilError, UtilErrorType, UtilResult};
110 use std::sync::{LazyLock, Mutex};
111
112 static REAPER_TESTS: LazyLock<tokio::sync::Mutex<()>> =
117 LazyLock::new(|| tokio::sync::Mutex::new(()));
118
119 #[derive(Default)]
122 struct RecordingFileStore {
123 deleted: Mutex<Vec<String>>,
124 }
125
126 #[async_trait::async_trait(?Send)]
127 impl FileStore for RecordingFileStore {
128 async fn upload(&self, _path: &Path, _contents: Vec<u8>, _mime: &str) -> UtilResult<()> {
129 unimplemented!("not reached by the reaper")
130 }
131
132 async fn upload_stream(
133 &self,
134 _path: &Path,
135 _contents: headless_lms_utils::file_store::GenericPayload,
136 _mime: &str,
137 ) -> UtilResult<()> {
138 unimplemented!("not reached by the reaper")
139 }
140
141 async fn download(&self, _path: &Path) -> UtilResult<Vec<u8>> {
142 unimplemented!("not reached by the reaper")
143 }
144
145 async fn download_stream(
146 &self,
147 _path: &Path,
148 ) -> UtilResult<Box<dyn futures::Stream<Item = std::io::Result<bytes::Bytes>>>> {
149 unimplemented!("not reached by the reaper")
150 }
151
152 async fn get_direct_download_url(&self, _path: &Path) -> UtilResult<String> {
153 unimplemented!("not reached by the reaper")
154 }
155
156 async fn get_direct_attachment_download_url(
157 &self,
158 _path: &Path,
159 _file_name: &str,
160 ) -> UtilResult<String> {
161 unimplemented!("not reached by the reaper")
162 }
163
164 async fn delete(&self, path: &Path) -> UtilResult<()> {
165 self.deleted
166 .lock()
167 .expect("lock")
168 .push(path.to_string_lossy().to_string());
169 Ok(())
170 }
171
172 fn get_cache_files_folder_path(&self) -> UtilResult<&Path> {
173 unimplemented!("not reached by the reaper")
174 }
175 }
176
177 fn deletions_of(recorded: &Mutex<Vec<String>>, path: &str) -> usize {
180 recorded
181 .lock()
182 .expect("lock")
183 .iter()
184 .filter(|recorded_path| recorded_path.as_str() == path)
185 .count()
186 }
187
188 #[actix_web::test]
194 async fn reaps_an_orphaned_upload_and_leaves_a_row_behind() {
195 let _serialized = REAPER_TESTS.lock().await;
196 insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
197 let path = "exercise-services-client/orphan";
198 let file_id = models::file_uploads::insert(
199 tx.as_mut(),
200 "orphan.tar.zst",
201 path,
202 "application/octet-stream",
203 Some(user),
204 None,
205 )
206 .await
207 .expect("file upload");
208 models::exercise_answer_uploads::insert_many(
209 tx.as_mut(),
210 exercise,
211 user,
212 &[file_id],
213 models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
214 )
215 .await
216 .expect("binding");
217 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
218 tx.commit().await;
219
220 let pool = PgPool::connect(&dev_database_url())
221 .await
222 .expect("test pool");
223 let file_store = RecordingFileStore::default();
224 reap(&pool, &file_store).await.expect("reap");
225
226 assert_eq!(deletions_of(&file_store.deleted, path), 1);
227 let mut check_conn = Conn::init().await;
228 let mut check_tx = check_conn.begin().await;
229 assert!(
230 models::file_uploads::get_many(check_tx.as_mut(), &[file_id])
231 .await
232 .expect("file lookup")
233 .is_empty()
234 );
235 let recorded = models::exercise_answer_uploads::get_for_exercise_and_user(
236 check_tx.as_mut(),
237 exercise,
238 user,
239 &[file_id],
240 )
241 .await
242 .expect("binding lookup");
243 assert_eq!(recorded.len(), 1);
244 assert!(recorded[0].deleted);
245 check_tx.rollback().await;
246 }
247
248 #[actix_web::test]
250 async fn spares_an_upload_inside_the_retention_window() {
251 let _serialized = REAPER_TESTS.lock().await;
252 insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
253 let file_id = models::file_uploads::insert(
254 tx.as_mut(),
255 "fresh.tar.zst",
256 "exercise-services-client/fresh",
257 "application/octet-stream",
258 Some(user),
259 None,
260 )
261 .await
262 .expect("file upload");
263 models::exercise_answer_uploads::insert_many(
264 tx.as_mut(),
265 exercise,
266 user,
267 &[file_id],
268 models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
269 )
270 .await
271 .expect("binding");
272 tx.commit().await;
273
274 let pool = PgPool::connect(&dev_database_url())
275 .await
276 .expect("test pool");
277 let file_store = RecordingFileStore::default();
278 reap(&pool, &file_store).await.expect("reap");
279
280 assert_eq!(
281 deletions_of(&file_store.deleted, "exercise-services-client/fresh"),
282 0
283 );
284 let mut check_conn = Conn::init().await;
285 let mut check_tx = check_conn.begin().await;
286 assert_eq!(
287 models::file_uploads::get_many(check_tx.as_mut(), &[file_id])
288 .await
289 .expect("file lookup")
290 .len(),
291 1
292 );
293 check_tx.rollback().await;
294 }
295
296 #[actix_web::test]
301 async fn spares_an_iframe_upload_a_native_client_upload_would_lose() {
302 let _serialized = REAPER_TESTS.lock().await;
303 insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
304 let path = "exercise-services-client/mid-exam";
305 let file_id = models::file_uploads::insert(
306 tx.as_mut(),
307 "mid-exam.pdf",
308 path,
309 "application/pdf",
310 Some(user),
311 None,
312 )
313 .await
314 .expect("file upload");
315 models::exercise_answer_uploads::insert_many(
316 tx.as_mut(),
317 exercise,
318 user,
319 &[file_id],
320 models::exercise_answer_uploads::AnswerUploadOrigin::Iframe,
321 )
322 .await
323 .expect("binding");
324 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
325 tx.commit().await;
326
327 let pool = PgPool::connect(&dev_database_url())
328 .await
329 .expect("test pool");
330 let file_store = RecordingFileStore::default();
331 reap(&pool, &file_store).await.expect("reap");
332
333 assert_eq!(deletions_of(&file_store.deleted, path), 0);
334 let mut check_conn = Conn::init().await;
335 let mut check_tx = check_conn.begin().await;
336 assert_eq!(
337 models::file_uploads::get_many(check_tx.as_mut(), &[file_id])
338 .await
339 .expect("file lookup")
340 .len(),
341 1
342 );
343 check_tx.rollback().await;
344 }
345
346 #[actix_web::test]
348 async fn reaps_an_iframe_upload_past_seven_days() {
349 let _serialized = REAPER_TESTS.lock().await;
350 insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
351 let path = "exercise-services-client/abandoned";
352 let file_id = models::file_uploads::insert(
353 tx.as_mut(),
354 "abandoned.pdf",
355 path,
356 "application/pdf",
357 Some(user),
358 None,
359 )
360 .await
361 .expect("file upload");
362 models::exercise_answer_uploads::insert_many(
363 tx.as_mut(),
364 exercise,
365 user,
366 &[file_id],
367 models::exercise_answer_uploads::AnswerUploadOrigin::Iframe,
368 )
369 .await
370 .expect("binding");
371 backdate(tx.as_mut(), file_id, Duration::days(8)).await;
372 tx.commit().await;
373
374 let pool = PgPool::connect(&dev_database_url())
375 .await
376 .expect("test pool");
377 let file_store = RecordingFileStore::default();
378 reap(&pool, &file_store).await.expect("reap");
379
380 assert_eq!(deletions_of(&file_store.deleted, path), 1);
381 let mut check_conn = Conn::init().await;
382 let mut check_tx = check_conn.begin().await;
383 assert!(
384 models::file_uploads::get_many(check_tx.as_mut(), &[file_id])
385 .await
386 .expect("file lookup")
387 .is_empty()
388 );
389 check_tx.rollback().await;
390 }
391
392 #[derive(Default)]
394 struct FailingFileStore {
395 attempts: Mutex<Vec<String>>,
396 }
397
398 #[async_trait::async_trait(?Send)]
399 impl FileStore for FailingFileStore {
400 async fn upload(&self, _path: &Path, _contents: Vec<u8>, _mime: &str) -> UtilResult<()> {
401 unimplemented!("not reached by the reaper")
402 }
403
404 async fn upload_stream(
405 &self,
406 _path: &Path,
407 _contents: headless_lms_utils::file_store::GenericPayload,
408 _mime: &str,
409 ) -> UtilResult<()> {
410 unimplemented!("not reached by the reaper")
411 }
412
413 async fn download(&self, _path: &Path) -> UtilResult<Vec<u8>> {
414 unimplemented!("not reached by the reaper")
415 }
416
417 async fn download_stream(
418 &self,
419 _path: &Path,
420 ) -> UtilResult<Box<dyn futures::Stream<Item = std::io::Result<bytes::Bytes>>>> {
421 unimplemented!("not reached by the reaper")
422 }
423
424 async fn get_direct_download_url(&self, _path: &Path) -> UtilResult<String> {
425 unimplemented!("not reached by the reaper")
426 }
427
428 async fn get_direct_attachment_download_url(
429 &self,
430 _path: &Path,
431 _file_name: &str,
432 ) -> UtilResult<String> {
433 unimplemented!("not reached by the reaper")
434 }
435
436 async fn delete(&self, path: &Path) -> UtilResult<()> {
437 self.attempts
438 .lock()
439 .expect("lock")
440 .push(path.to_string_lossy().to_string());
441 Err(UtilError::new(
442 UtilErrorType::Other,
443 "simulated object store failure".to_string(),
444 None,
445 ))
446 }
447
448 fn get_cache_files_folder_path(&self) -> UtilResult<&Path> {
449 unimplemented!("not reached by the reaper")
450 }
451 }
452
453 #[actix_web::test]
459 async fn a_failed_object_delete_fails_the_run_and_is_retried() {
460 let _serialized = REAPER_TESTS.lock().await;
461 insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
462 let path = "exercise-services-client/transient";
463 let file_id = models::file_uploads::insert(
464 tx.as_mut(),
465 "transient.tar.zst",
466 path,
467 "application/octet-stream",
468 Some(user),
469 None,
470 )
471 .await
472 .expect("file upload");
473 models::exercise_answer_uploads::insert_many(
474 tx.as_mut(),
475 exercise,
476 user,
477 &[file_id],
478 models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
479 )
480 .await
481 .expect("binding");
482 backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
483 tx.commit().await;
484
485 let pool = PgPool::connect(&dev_database_url())
486 .await
487 .expect("test pool");
488 let failing = FailingFileStore::default();
489 reap(&pool, &failing)
490 .await
491 .expect_err("a run where every delete failed must not exit successfully");
492 assert_eq!(deletions_of(&failing.attempts, path), 1);
493 let mut check_conn = Conn::init().await;
494 let mut check_tx = check_conn.begin().await;
495 assert_eq!(
496 models::file_uploads::get_many(check_tx.as_mut(), &[file_id])
497 .await
498 .expect("file lookup")
499 .len(),
500 1,
501 "the file row must survive, since it is what makes the retry possible"
502 );
503 check_tx.rollback().await;
504
505 let retry = RecordingFileStore::default();
506 reap(&pool, &retry).await.expect("retry");
507 assert_eq!(deletions_of(&retry.deleted, path), 1);
508 let mut check_conn = Conn::init().await;
509 let mut check_tx = check_conn.begin().await;
510 assert!(
511 models::file_uploads::get_many(check_tx.as_mut(), &[file_id])
512 .await
513 .expect("file lookup")
514 .is_empty()
515 );
516 check_tx.rollback().await;
517 }
518
519 #[actix_web::test]
530 async fn a_concurrent_reaper_blocks_on_the_submit_lock_and_then_declines_to_reap() {
531 let _serialized = REAPER_TESTS.lock().await;
532 insert_data!(:tx, user: user, :org, course: course, instance: _instance, :course_module, :chapter, :page, :exercise, slide: slide, task: task);
536 let file_id = models::file_uploads::insert(
537 tx.as_mut(),
538 "raced.tar.zst",
539 "exercise-services-client/raced",
540 "application/octet-stream",
541 Some(user),
542 None,
543 )
544 .await
545 .expect("file upload");
546 models::exercise_answer_uploads::insert_many(
547 tx.as_mut(),
548 exercise,
549 user,
550 &[file_id],
551 models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
552 )
553 .await
554 .expect("binding");
555 let binding_id = binding_id_of(tx.as_mut(), file_id).await;
556 tx.commit().await;
557
558 let mut submit_conn = Conn::init().await;
561 let mut submit_tx = submit_conn.begin().await;
562 let locked = models::exercise_answer_uploads::lock_for_exercise_and_user(
563 submit_tx.as_mut(),
564 exercise,
565 user,
566 &[file_id],
567 )
568 .await
569 .expect("locked lookup");
570 assert_eq!(locked.len(), 1);
571 assert!(!locked[0].deleted);
572
573 let mut reaper_conn = Conn::init().await;
575 let mut reaper_tx = reaper_conn.begin().await;
576 let reaped = {
578 let mut reap = std::pin::pin!(models::exercise_answer_uploads::mark_reaped(
579 reaper_tx.as_mut(),
580 binding_id
581 ));
582 assert!(
583 tokio::time::timeout(std::time::Duration::from_millis(500), &mut reap)
584 .await
585 .is_err(),
586 "the reaper must block on the row lock the submit holds, not decide without it"
587 );
588
589 let slide_submission = models::exercise_slide_submissions::insert_exercise_slide_submission(
590 submit_tx.as_mut(),
591 models::exercise_slide_submissions::NewExerciseSlideSubmission {
592 exercise_slide_id: slide,
593 course_id: Some(course),
594 exam_id: None,
595 user_id: user,
596 exercise_id: exercise,
597 user_points_update_strategy:
598 models::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
599 },
600 )
601 .await
602 .expect("slide submission");
603 let task_submission = models::exercise_task_submissions::insert(
604 submit_tx.as_mut(),
605 models::PKeyPolicy::Generate,
606 slide_submission.id,
607 slide,
608 task,
609 &models::library::grading::SubmittedAnswer::Json {
610 data: serde_json::json!({ "opaque": "plugin owned" }),
611 },
612 )
613 .await
614 .expect("task submission");
615 models::exercise_task_submission_files::insert_many(
616 submit_tx.as_mut(),
617 task_submission,
618 &[file_id],
619 )
620 .await
621 .expect("submission files");
622 submit_tx.commit().await;
623
624 tokio::time::timeout(std::time::Duration::from_secs(10), &mut reap)
625 .await
626 .expect("the reaper must unblock once the submit commits")
627 .expect("mark_reaped")
628 };
629 assert!(
630 !reaped,
631 "the reaper must decline an upload the submit referenced while it waited"
632 );
633 reaper_tx.rollback().await;
634
635 let mut check_conn = Conn::init().await;
636 let mut check_tx = check_conn.begin().await;
637 let recorded = models::exercise_answer_uploads::get_for_exercise_and_user(
638 check_tx.as_mut(),
639 exercise,
640 user,
641 &[file_id],
642 )
643 .await
644 .expect("binding lookup");
645 assert_eq!(
646 recorded,
647 vec![models::exercise_answer_uploads::AnswerUpload {
648 file_upload_id: file_id,
649 deleted: false
650 }],
651 "the upload must stay usable, so download_submission can still serve it"
652 );
653 check_tx.rollback().await;
654 }
655
656 async fn binding_id_of(conn: &mut PgConnection, file_upload_id: uuid::Uuid) -> uuid::Uuid {
657 models::exercise_answer_uploads::get_id_by_file_upload_id(conn, file_upload_id)
658 .await
659 .expect("binding id")
660 }
661
662 async fn backdate(conn: &mut PgConnection, file_upload_id: uuid::Uuid, age: Duration) {
663 models::exercise_answer_uploads::backdate(conn, file_upload_id, age)
664 .await
665 .expect("backdate");
666 }
667}