Skip to content

Commit 006dbf6

Browse files
committed
chore(marketplace): reap aged preview submissions on a TTL
Weekly, keyed on last activity rather than creation so an in-progress rehearsal is never reaped out from under someone. Never touches the container course, the assessment copies or the previewers' enrolments — those are deliberately reused across preview sessions. The cron sets only how long past the TTL a submission may linger, not how long it is kept: starting over is the banner's Reset submission button, not this.
1 parent 37e296a commit 006dbf6

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
# Reaps aged marketplace preview submissions on a TTL, scheduled weekly via `config/schedule.yml`
3+
# (`Course::Assessment::Marketplace::PreviewContainerService`'s container course is the only source of
4+
# these submissions). Never touches the container course, the assessment copies, or the previewers'
5+
# enrolments — those are deliberately persistent and reused across preview sessions.
6+
class Course::Assessment::Marketplace::PreviewSubmissionReapingJob < ApplicationJob
7+
# Keyed on `updated_at` (last activity), not `created_at`, so an in-progress rehearsal is never
8+
# reaped out from under someone still working, and async autograding has time to land before a
9+
# destroy could race it.
10+
#
11+
# This is the floor on how long an attempt is kept, not the ceiling: the weekly cron means an aged
12+
# submission may linger up to a week past it.
13+
PREVIEW_SUBMISSION_TTL = 24.hours
14+
15+
# Cap deletions per run to avoid bricking the worker (mirrors UserEmailDatabaseCleanupJob). Note
16+
# this caps a WEEK's reaping, not an hour's: if preview volume ever exceeds it, aged submissions
17+
# accumulate faster than they are removed and the cron needs raising before this does.
18+
REAP_BATCH_SIZE = 1000
19+
20+
def perform
21+
ActsAsTenant.without_tenant do
22+
reap_aged_preview_submissions
23+
end
24+
end
25+
26+
private
27+
28+
def reap_aged_preview_submissions
29+
User.with_stamper(User.system) do
30+
Course::Assessment::Submission.transaction do
31+
aged_preview_submissions.group_by(&:assessment).each do |assessment, submissions|
32+
creator_ids = []
33+
submissions.each do |submission|
34+
submission.destroy!
35+
creator_ids << submission.creator_id
36+
end
37+
38+
Course::Assessment::Submission::MonitoringService.destroy_all_by(assessment, creator_ids)
39+
end
40+
end
41+
end
42+
end
43+
44+
# Derived from the course, not a deep join: `Course::Assessment` is `acts_as` a
45+
# `Course::LessonPlan::Item`, so a `joins(assessment: { tab: :category })` chain is fragile.
46+
def aged_preview_submissions
47+
preview_assessment_ids = Course.where(preview: true).flat_map { |course| course.assessments.pluck(:id) }
48+
49+
Course::Assessment::Submission.
50+
includes(:assessment).
51+
where(assessment_id: preview_assessment_ids).
52+
where(updated_at: ...PREVIEW_SUBMISSION_TTL.ago).
53+
limit(REAP_BATCH_SIZE)
54+
end
55+
end

config/schedule.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ user_email_database_cleanup_job:
1515
cron: '0 0 1 * *'
1616
class: 'UserEmailDatabaseCleanupJob'
1717
queue: 'default'
18+
19+
# Reap aged marketplace preview submissions every Sunday at 21:20 UTC, 5:20 AM SGT Monday. The TTL
20+
# is enforced in the job, not the cron, so this interval sets only how long past the TTL a submission
21+
# may linger — not how long it is kept. Previewers do not wait on it to start over; the preview
22+
# banner's "Reset submission" button clears an attempt on demand.
23+
preview_submission_reaping_job:
24+
cron: '20 21 * * 0'
25+
class: 'Course::Assessment::Marketplace::PreviewSubmissionReapingJob'
26+
queue: 'default'
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
require 'rails_helper'
3+
4+
RSpec.describe Course::Assessment::Marketplace::PreviewSubmissionReapingJob, type: :job do
5+
# Own instance: at most one `preview: true` course may exist per instance, and the job runs
6+
# `without_tenant`, so it reaps this course's submissions wherever the course lives.
7+
let(:instance) { create(:instance) }
8+
9+
with_tenant(:instance) do
10+
let(:ttl) { described_class::PREVIEW_SUBMISSION_TTL }
11+
12+
subject { described_class.perform_now }
13+
14+
# `update_column` bypasses `updated_at`'s own touch, unlike `update!`/`touch`.
15+
def age!(submission, ago:)
16+
submission.update_column(:updated_at, ago)
17+
end
18+
19+
context 'a preview submission older than the TTL' do
20+
let!(:preview_course) { create(:course, preview: true) }
21+
let!(:preview_assessment) { create(:assessment, :with_mcq_question, course: preview_course) }
22+
let!(:previewer) { create(:user) }
23+
let!(:aged_submission) do
24+
submission = create(:submission, :attempting, assessment: preview_assessment,
25+
course: preview_course, creator: previewer)
26+
age!(submission, ago: (ttl + 1.hour).ago)
27+
submission
28+
end
29+
30+
it 'reaps the aged preview submission' do
31+
expect { subject }.
32+
to change { Course::Assessment::Submission.exists?(aged_submission.id) }.from(true).to(false)
33+
end
34+
35+
it 'cascades: the reaped submission\'s answers are destroyed too' do
36+
expect { subject }.
37+
to change { Course::Assessment::Answer.where(submission_id: aged_submission.id).exists? }.
38+
from(true).to(false)
39+
end
40+
41+
it 'leaves the preview course, the assessment, and the enrolment alone' do
42+
subject
43+
44+
expect(Course.exists?(preview_course.id)).to be(true)
45+
expect(Course::Assessment.exists?(preview_assessment.id)).to be(true)
46+
expect(preview_course.course_users.exists?(user_id: previewer.id)).to be(true)
47+
end
48+
end
49+
50+
context 'a preview submission within the TTL grace period' do
51+
let!(:preview_course) { create(:course, preview: true) }
52+
let!(:preview_assessment) { create(:assessment, :with_mcq_question, course: preview_course) }
53+
let!(:previewer) { create(:user) }
54+
let!(:fresh_submission) do
55+
create(:submission, :attempting, assessment: preview_assessment,
56+
course: preview_course, creator: previewer)
57+
end
58+
59+
it 'spares the submission' do
60+
expect { subject }.
61+
not_to(change { Course::Assessment::Submission.exists?(fresh_submission.id) })
62+
end
63+
end
64+
65+
# The highest-value example: proves the job scopes to `preview: true` courses rather than
66+
# reaping any old submission it finds.
67+
context 'a NON-preview submission of the same age' do
68+
let!(:normal_course) { create(:course) }
69+
let!(:normal_assessment) { create(:assessment, :with_mcq_question, course: normal_course) }
70+
let!(:normal_user) { create(:user) }
71+
let!(:aged_normal_submission) do
72+
submission = create(:submission, :attempting, assessment: normal_assessment,
73+
course: normal_course, creator: normal_user)
74+
age!(submission, ago: (ttl + 1.hour).ago)
75+
submission
76+
end
77+
78+
it 'spares the non-preview submission' do
79+
expect { subject }.
80+
not_to(change { Course::Assessment::Submission.exists?(aged_normal_submission.id) })
81+
end
82+
end
83+
end
84+
end

0 commit comments

Comments
 (0)