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

Cannot archive a deleted LearningOutcomeGroup

Error message

Cannot archive a deleted LearningOutcomeGroup

What it means

LearningOutcomeGroup#archive! archives an active outcome group, stamping archived_at. Archiving a deleted group is an invalid state transition, raising ActiveRecord::RecordNotSaved when workflow_state is 'deleted'.

Solutions

  1. Filter scope to active groups: where(workflow_state: 'active').each(&:archive!).
  2. Reload the record and verify state == 'active' before archiving.
  3. Rescue ActiveRecord::RecordNotSaved per group in bulk operations so one bad record doesn't abort the loop.
  4. Do nothing if the group is already deleted.

Example fix

// before
LearningOutcomeGroup.where(context: course).each(&:archive!)
// after
LearningOutcomeGroup.where(context: course, workflow_state: "active").each(&:archive!)
Defensive patterns

Strategy: try-catch

Validate before calling

groups = groups.where(workflow_state: "active")
groups.each(&:archive!)

Try / catch

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

Prevention

When it happens

Trigger: Calling group.archive! on a LearningOutcomeGroup with workflow_state 'deleted', e.g. from a stale UI list or concurrent deletion while archiving.

Common situations: Outcome group deleted while another tab or job archives it; bulk archival scripts iterating a stale relation including deleted groups.

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/e161c747b2cbc147. Report an issue: GitHub.

Appendix: source

Thrown at app/models/learning_outcome_group.rb:279

      end
      child_outcome_groups.active.each do |outcome_group|
        outcome_group.skip_tag_touch = true if @skip_tag_touch
        outcome_group.destroy
      end

      self.workflow_state = "deleted"
      save!
    end
  end

  def archive!
    # Only active groups 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 LearningOutcomeGroup"
    end
  end

  def unarchive!
    # Only archived groups 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 LearningOutcomeGroup"
    end
  end

  scope :active, -> { where("learning_outcome_groups.workflow_state NOT IN ('deleted', 'archived')") }
  scope :active_first, -> { order(Arel.sql("CASE WHEN workflow_state = 'active' THEN 0 ELSE 1 END")) }
  scope :archived, -> { where("learning_outcome_groups.workflow_state = 'archived' AND learning_outcome_groups.archived_at IS NOT NULL") }

View on GitHub (pinned to 1c9f0bb801)