feat(submission): offload background operations from finalise transaction - #8579
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The after-commit Cikgo push can still raise outside production, causing finalise/update calls to error after the DB commit, which is a correctness/UX regression relative to the stated intent.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Moves expensive and failure-prone side effects out of the submission finalise transaction so derived-state failures (timeline recomputation, notifications, Cikgo push) can’t roll back committed student work.
Changes:
- Enqueue per-user personalised timeline recomputation via a new
Course::LessonPlan::PersonalizedTimelineUpdateJobafter all transactions commit. - Defer Cikgo task status push and submission activity/notification creation to
after_all_transactions_commit. - Add/adjust specs and test helpers to ensure deferred work is observable only after commit and to reduce background-thread job races in specs.
File summaries
| File | Description |
|---|---|
| spec/support/active_job.rb | Adds wait_for_enqueued_jobs helper to block on background-thread adapter execution during spec setup. |
| spec/models/course/assessment/submission_spec.rb | Adds commit-boundary assertions for deferred timeline job, activity creation, and Cikgo push behavior. |
| spec/jobs/course/lesson_plan/personalized_timeline_update_job_spec.rb | New job spec covering enqueueing, shifting behavior, and idempotency. |
| spec/controllers/concerns/course/lesson_plan/personalization_concern_spec.rb | Settles per-user recomputation jobs before running coursewide recomputation assertions. |
| app/models/concerns/course/assessment/submission/workflow_event_concern.rb | Replaces inline timeline recompute with after-commit job enqueue. |
| app/models/concerns/course/assessment/submission/notification_concern.rb | Defers activity/notification creation until after commit. |
| app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb | Defers Cikgo push until after commit via a wrapper callback. |
| app/jobs/course/lesson_plan/personalized_timeline_update_job.rb | New ActiveJob to recompute a single course user’s personalised timeline under the correct tenant. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
adi-herwana-nus
force-pushed
the
adi/finalise-transaction-offloading
branch
from
September 7, 2026 20:54
8b79e66 to
90b5b0d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The third mitigation following the duplicate
course_personal_timesincident, after#8559 (client fix: debounce the lesson plan
editor so it stops firing overlapping PATCHes) and
#8560 (unique index on
(course_user_id, lesson_plan_item_id)).Background
Two
Course::LessonPlan::CoursewidePersonalizedTimelineUpdateJobruns executing concurrently eachinserted a
Course::PersonalTimerow for the same(course_user_id, lesson_plan_item_id)pair.Neither failed: the uniqueness check was a model-level validation with no backing index, so neither
transaction saw the other's uncommitted row.
Once a duplicate pair existed, every later personalisation run for that course user failed
deterministically — for an existing record the validation excludes only itself, so it always found the
twin. Finalising a submission recomputes the submitter's timeline, and did so inside the same
transaction that commits the student's work. So the exception unwound the whole submission: affected
students could not finalise anything in that course, on every retry, until we intervened.
#8560 stops the duplicates being created. This PR stops a failure in derived, recomputable state from
being able to destroy student work in the first place.
What moves out of the transaction
The transaction is opened in
Course::Assessment::Submission::UpdateService#update_submissionandcovers both the answer writes and the workflow transition — the
finalisehandler'ssave!onlyopens a joined nested transaction, so every
after_savecallback is part of it too.1. Personalised timeline recomputation
update_personalized_timeline_for_useris replaced by a newCourse::LessonPlan::PersonalizedTimelineUpdateJob, enqueued fromActiveRecord.after_all_transactions_commit.This is by far the largest unit of work in the transaction:
precompute_dataloads every lesson planitem in the course with its reference and personal times, builds the user's submission-time hash and
computes a learning-rate EMA over it;
executethen walks that list and inserts or updates a personaltime per shiftable item. It is also the only part that contends with the coursewide job for the same
rows.
Enqueuing after commit has an incidental benefit: the job now reads a committed submission, which is
what
submitted_itemswas always meant to include.Course::LessonPlan::PersonalizationConcernis no longer included in the submission model.2. Cikgo task completion
publish_task_completionis a synchronousExconPATCH with a five second timeout. It moves into anew
Course::Assessment::Submission::PublishTaskCompletionJob, enqueued after commit, so the requestneither waits on Cikgo nor fails because of it.
Two things worth knowing when reviewing this one:
attempting → submittedit is semantically a no-op.WORKFLOW_STATE_TO_TASK_COMPLETION_STATUSmaps both
attemptingandsubmittedto:ongoing, so finalising pushes the status Cikgo alreadyhas. Only
→ publishedcarries new information.raise e unless Rails.env.production?, so production logs and swallows. The production cost was up to five secondsof an open transaction holding locks on the submission and its answers while waiting on a third
party. Outside production the behaviour was the inverted one, where a Cikgo hiccup did roll a
finalise back.
The method is split so the two call sites get the error handling each needs, rather than one swallowing
compromise:
publish_task_completion!pushes and lets failures propagate. The job uses it, so a failure isretried by Sidekiq and reported to Rollbar (
ApplicationJobincludesRollbar::ActiveJob, whichreports and re-raises) instead of vanishing into the log.
publish_task_completionkeeps the existing log-and-swallow-in-production behaviour forSubmissionsController#publish_cikgo_task_completion, theafter_actiononeditthat repairs amissed push. That path is unchanged by this PR.
The job re-reads
should_publish_task_completion?at run time rather than trusting the state atenqueue, so a superseded push is dropped and two pushes racing each other converge on the current
status.
3. Submission notification
Email delivery was already deferred to after commit by
Notifier::Base::ActivityWrapper. The queriesbehind it were not: building the activity walks the submitter's groups to find managers, falls back to
course managers, subtracts the unsubscribed ones, and writes the activity and its notification rows.
That now happens after commit too. The guards stay inline, since they read
workflow_state_before_last_save.This one stays on the request rather than becoming a job of its own, since its real work is enqueuing
mail delivery jobs.
What deliberately stays
finalise_current_answers,submitted_at— the student's work and thedefinition of the state transition.
assign_zero_experience_points— not a side effect. An assessment with no questions finalisesstraight to
published, skipping the states where grading would assign points, andpublishedcarries
validate_awarded_attributes. It is three in-memory assignments on the record being saved,persisted by the outer
update!'s ownsave!; there is no separate write to move, and deferring itwould mean saving a record its own validation rejects.
update_todo— one row in the same database. Deferring it buys no latency and would leave thestudent's landing page showing a submitted assessment as still in progress, with nothing to
reconcile it.
Consequences if the offloaded work fails
The point of the change is that these are now recoverable rather than destructive, so they are worth
stating explicitly:
Course::LearningRateRecordrowexecuteand never read back —compute_learning_rate_emare-derives from submissions and personal times each run. Its only readers are the personal times and course user pages.force_submit_atderives from the timeline, so a stale timeline can leave a job scheduled against a deadline that would have shifted.ScheduleExpiringSubmissionsJobruns hourly and reschedules any submission whoseforce_submit_atno longer matches its recordedforce_submit_scheduled_at, andForceSubmitTimedSubmissionJobre-checks live before submitting.SubmissionsControlleralready re-pushes onedit.Why this is safe without locking
Offloading widens the window in which the recomputation can overlap a coursewide run. That overlap was
always possible — the finalise transaction took no lock that excluded the job, which is exactly how the
duplicates were created — so this is a widening, not a new exposure. With #8560 already merged, what
changes is the outcome when two runs do collide:
Locking would reduce the collisions themselves, but nothing here depends on it: a collision is now
transient and self-correcting, and a run that fails permanently leaves a stale timeline rather than
lost work.
Behaviour changes
deadlines may lag their submission by however long the job queue takes. The recomputation runs on
:lowest, matchingCoursewidePersonalizedTimelineUpdateJob.submission.
in Rollbar rather than only in the production log.
perform_later/notifier calls remain on the request after the commit, and an enqueue failure therewould already be failing
auto_grade_submissionon the same path.Testing
New
spec/jobs/course/lesson_plan/personalized_timeline_update_job_spec.rb: the job is queued,enqueued by finalising, shifts only the unsubmitted item, no-ops on the fixed algorithm, and — the one
that matters here — running it twice neither duplicates rows nor drifts.
New
spec/jobs/course/assessment/submission/publish_task_completion_job_spec.rb: the job pushes thecurrent status, drops a push whose submission no longer qualifies, and lets a failed push propagate,
which is what puts it in front of Sidekiq's retries and Rollbar.
New
work deferred past the finalise transactiongroup inspec/models/course/assessment/submission_spec.rb, asserting each of the three is invisible while thetransaction is open, absent entirely if it rolls back, and — for the Cikgo push — enqueued rather than
called during the request.
Checked against the old behaviour by restoring
master's three concerns and re-running the group: 4of its 6 examples fail, including the Cikgo rollback case, which errors on
masterbecause theinline push raises inside the transaction. The two that pass beforehand are the rollback cases for the
timeline and the activity — vacuously, since on
masterthat work was inside the transaction and rolledback with it. They are kept as forward guards against the deferral being moved back out of the
after-commit hook.
The notification assertion goes through
Activity.countrather than a message expectation, becauseassessment_submittedis reached viaNotifier::Base.method_missingand cannot be a verified double.One spec-infrastructure note for reviewers. The offload makes the suite run these jobs concurrently
with example bodies, since the background thread adapter only joins them when the example ends. That
surfaced a real failure in
personalization_concern_spec.rb— three per-user recomputations racing thecoursewide run under test, which is the incident in miniature. This PR adds a
wait_for_enqueued_jobshelper to
spec/support/active_job.rband settles the setup before that example. Other specs thatfinalise a submission in setup and then assert on personal times may need the same.
Run locally: the submission model, controller, service, job, personalisation-concern and notifier specs
all pass. Feature specs run:
spec/features/course/assessment/assessment_attempt_spec.rband all ofspec/features/course/assessment/submission/— 66 examples, one failure, which is a pre-existing localKeycloak login problem (
submissions_spec.rb:54dies inlogin_asbefore it reaches a submission, andfails identically with this branch's changes reverted).
Follow-ups, not in this PR
Course::PersonalTimesController#recomputestill runs the recomputation synchronously in therequest, with the same "hundreds of items in one request" shape removed from finalise here. It should
go through the same job.
Course::LearningRateRecord's timestamp is effectively a "last successfully recomputed at" marker,and surfacing it on the personal times page would make a failed recomputation visible to
instructors. Useful, but a feature rather than part of this fix.
were not examined; only the assessment path is covered here.