ActiveRecord::Base.transaction: all your writes, or none of them

Free preview 3 min to read

A transaction the earlier courses relied on, without writing one

ActiveRecord migrations: multi-table showed that Rails wraps an ordinary migration's whole change method in one database transaction, so several create_table calls either all land or none do. Nobody wrote that transaction by hand; Rails supplied it. This course covers the same guarantee written explicitly in application code. You need it when more than one related write has to succeed or fail together and nothing supplies the transaction for you.

This app's code, doing that

Enrollment::CompleteLessonService is the service that runs when a student finishes a lesson:

# app/services/enrollment/complete_lesson_service.rb (excerpt)
completion = ::Enrollment::LessonCompletion.new(user: user, lesson: lesson)

ActiveRecord::Base.transaction do
  return failure(next_lesson, completion.errors.full_messages) unless completion.save

  xp_before = ::Enrollment::ExperiencePoint.total_for(user.id)

  lesson_xp = ::Enrollment::AwardXpService.call(
    user: user, source: "lesson_completion", course: lesson.course, idempotency_key: "lesson_completion:#{lesson.id}"
  )
  ::Enrollment::UpdateStreakService.call(user: user)
  course_result = ::Enrollment::CompleteCourseService.call(user: user, course: lesson.course)
  # ... then EvaluateUnlocksService awards any new badges
end

One call produces a LessonCompletion row, an XP award, a streak update, a possible course completion and possible badges: four or five separate writes to separate tables. Without a transaction around them, a failure partway through could leave a student with XP but no completion recorded, or a completion with no XP. A dropped database connection would do it, and so would an unrelated bug in a later service. Nothing in the app would know how to repair that state.

What the guarantee covers

ActiveRecord::Base.transaction do ... end is one call, and what it buys is specific: every write inside the block becomes durable together, at the same moment, or none of them do. The database transactions the migrations courses leaned on for schema changes give ordinary rows the same all-or-nothing property. Either all of Enrollment::CompleteLessonService's writes land the instant the block finishes cleanly, or none of them do. If something inside goes wrong, the database looks as if the block never ran.

What "goes wrong" means here

That last sentence hides the rest of this course. Not every way a block can end early triggers the rollback. The next lesson tests which ones do, and the code above has a line, return failure(...) unless completion.save, sitting on the boundary between the two.

Key takeaways

  • ActiveRecord migrations: multi-table relied on this guarantee, supplied automatically, for schema changes; this course is the same mechanism written by hand in application code.
  • This app's Enrollment::CompleteLessonService wraps several separate writes (a lesson completion, XP, a streak update, a course completion, badges) in one ActiveRecord::Base.transaction do ... end block.
  • The guarantee is all or nothing: every write inside the block becomes durable together, or, if anything inside fails, the database looks as if the block never ran.
  • Not every way a block can end counts as failing. That distinction, and the line in this app's code that sits on it, is the next lesson.
Lesson 1 of 7 · free

Keep going with lesson 2

What triggers a rollback

Tests three ways to leave a transaction block: a raised exception rolls back, while a bare return commits.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...