ActiveRecord callback là cách dễ dàng chạy mã trong các giai đoạn khác nhau của vòng đời model.
Ví dụ, giả sử bạn có trang Q&A, và bạn muốn có thể tìm kiếm qua tất cả câu hỏi. Mỗi lần bạn thay đổi câu hỏi, bạn muốn index nó trong cái gì đó như ElasticSearch. Indexing mất chút thời gian và không gấp, vậy bạn làm nó trong background với Sidekiq.
Điều này dường như là lúc hoàn hảo cho after_save callback! Vì vậy trong model, bạn sẽ viết gì đó như:
class Question < ActiveRecord::Base after_save :index_for_search # ... private def index_for_search QuestionIndexerJob.perform_later(self) end end
class QuestionIndexerJob < ActiveJob::Base queue_as :default def perform(question) # ... index the question ... end end
Điều này hoạt động tốt! Hoặc, ít nhất, dường như vậy. Cho đến khi bạn hàng đợi nhiều job hơn và thấy lỗi này xuất hiện:
2015-03-10T05:29:02.881Z 52530 TID-oupf889w4 WARN: Error while trying to deserialize arguments: Couldn't find Question with 'id'=3
Chắc, Sidekiq sẽ retry job và nó có thể hoạt động lần sau. Nhưng vẫn hơi kỳ lạ. Tại sao Sidekiq không tìm thấy câu hỏi bạn vừa lưu?
Điều kiện cuộc đua giữa các process
Rails gọi after_save callback ngay sau bản ghi lưu. Nhưng bản ghi đó không thể được thấy bởi kết nối database khác, như cái Sidekiq đang dùng, cho đến khi database transaction được committed, điều đó xảy ra sau một chút. Điều này nghĩa là có cơ hội Sidekiq sẽ thử tìm câu hỏi của bạn sau khi bạn lưu, nhưng trước khi bạn commit. Nó không tìm thấy bản ghi, và nó nổ.
Vấn đề này phổ biến đến mức Sidekiq có mục FAQ về nó. Và có cách sửa dễ dàng.
Thay vì after_save:
class Question < ActiveRecord::Base after_save :index_for_search # ... end
dùng after_commit:
class Question < ActiveRecord::Base after_commit :index_for_search # ... end
Và job của bạn sẽ không được hàng đợi cho đến khi Sidekiq có thể thấy model của bạn.
Vì vậy, khi bạn hàng đợi background job hoặc nói cho process khác biết về thay đổi bạn vừa làm, dùng after_commit. Nếu bạn không, chúng có thể không tìm thấy bản ghi bạn vừa chạm.
Nhưng còn một vấn đề nữa…
Ừ, bạn chuyển một đống after_save hook sang dùng after_commit thay vào đó. Mọi thứ dường như hoạt động. Thời gian check-in tất cả và về nhà, phải không?
Trước, bạn muốn chạy test:
require 'test_helper' class QuestionTest < ActiveSupport::TestCase test "A saved question is queued for indexing" do assert_enqueued_with(job: QuestionIndexerJob) do Question.create(title: "Is it legal to kill a zombie?") end end end
1) Failure: QuestionTest#test_A_saved_question_is_queued_for_indexing [/Users/jweiss/Source/testapps/after_commit/test/models/question_test.rb:7]: No enqueued job found with {:job=>QuestionIndexerJob}
Oops! Se test phải hàng đợi job? Điều gì vừa xảy ra?
Mặc định, Rails gói mỗi test case trong transaction database riêng. Điều này có thể tăng tốc mọi thứ thực sự. Chỉ mất một lệnh database để undo tất cả thay đổi bạn làm trong test.
Nhưng điều này cũng nghĩa là after_commit callback của bạn sẽ không chạy. Bởi vì after_commit callback chỉ chạy khi transaction ngoài cùng đã committed.
Khi bạn gọi save bên trong test case, nó vẫn commit transaction (vài thứ), nhưng đó là transaction thứ hai ngoài cùng bây giờ. Vì vậy after_commit callback của bạn sẽ không chạy khi bạn mong đợi. Và bạn không thể test điều gì xảy ra bên trong chúng.
Vấn đề này cũng có cách sửa dễ dàng. Include gem test_after_commit trong Gemfile:
group :test do gem "test_after_commit" end
Và after_commit hook của bạn sẽ chạy sau khi transaction thứ hai từ cuối commit. Đó là điều bạn mong đợi xảy ra.
Bạn có thể nghĩ, "Đó kỳ lạ. Tại sao tôi phải dùng gem riêng để test callback đi kèm Rails? Se nó không nên tự động xảy ra?"
Bạn đúng. Nó kỳ lạ. Nhưng nó sẽ không kỳ lạ lâu.
Khi Rails 5 ship, bạn không phải lo về test_after_commit. Bởi vì vấn đề này đã được sửa trong Rails khoảng tháng trước.
Trong mã của tôi, tôi dùng after_commit nhiều. Tôi có thể dùng nó nhiều hơn after_save! Nhưng nó không đến mà không có vấn đề và trường hợp cạnh kỳ lạ.
Phiên bản sau phiên bản, nó đang tốt hơn. Và khi bạn dùng after_commit ở đúng chỗ, nhiều exception ngẫu nhiên, kỳ lạ sẽ không bao giờ xảy ra nữa.