instructure/canvas-lms · error · ActiveRecord::RecordNotSaved

Cannot archive a deleted LearningOutcome

Error message

Cannot archive a deleted LearningOutcome

What it means

LearningOutcome#archive! moves an active outcome to 'archived' and stamps archived_at. Archiving a deleted outcome is an invalid transition, so when workflow_state is 'deleted' it raises ActiveRecord::RecordNotSaved.

Solutions

  1. Guard before calling: archive! only if workflow_state == 'active'.
  2. Reload the record to check current state before archiving.
  3. Rescue ActiveRecord::RecordNotSaved and treat as a no-op when deleted.
  4. Permanently delete instead of archiving if that was the intent.

Example fix

// before
outcome.archive!
// after
outcome.reload.archive! if outcome.reload.workflow_state == "active"
Defensive patterns

Strategy: try-catch

Validate before calling

outcome.reload.archive! if outcome.reload.workflow_state == "active"

Try / catch

begin
  outcome.archive!
rescue ActiveRecord::RecordNotSaved
  Rails.logger.info("outcome #{outcome.id} already deleted; skipping archive")
end

Prevention

When it happens

Trigger: Calling outcome.archive! on a LearningOutcome whose workflow_state is 'deleted' — e.g. acting on a stale record after another process deleted it.

Common situations: Race between deletion (importer, outcome UI) and an archive request; background jobs replaying archive on already-deleted outcomes.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/b26fa2deb16fbc0c. Report an issue: GitHub.

Appendix: source

Thrown at app/models/learning_outcome.rb:412

    # in case this got called in a console, delete the alignments also. the UI
    # won't (shouldn't) allow deleting the outcome if there are still
    # alignments, so this will be a no-op in that case. either way, these are
    # not outcome links, so ContentTag#destroy is just changing the
    # workflow_state; use update_all for efficiency.
    ContentTag.learning_outcome_alignments.active.where(learning_outcome_id: self).update_all(workflow_state: "deleted")

    self.workflow_state = "deleted"
    save!
  end

  def archive!
    # Only active outcomes can be archived
    if workflow_state == "active"
      self.workflow_state = "archived"
      self.archived_at = Time.now.utc
      save!
    elsif workflow_state == "deleted"
      raise ActiveRecord::RecordNotSaved, "Cannot archive a deleted LearningOutcome"
    end
  end

  def unarchive!
    # Only archived outcomes can be unarchived
    if workflow_state == "archived"
      self.workflow_state = "active"
      self.archived_at = nil
      save!
    elsif workflow_state == "deleted"
      raise ActiveRecord::RecordNotSaved, "Cannot unarchive a deleted LearningOutcome"
    end
  end

  def assessed?(course = nil)
    if course
      learning_outcome_results.active.where(context_id: course, context_type: "Course").exists?
    elsif learning_outcome_results.active.loaded?

View on GitHub (pinned to 1c9f0bb801)