Нетерпеливый Globalize и неоднозначный ActiveRecord
13/03/2019
История еще одного расследования. Недавно мой проект частично перешел на стратегию сборки Rails приложений AMI + EBS через Packer + Terraform. В рамках этого перехода всплыла одна интересная деталь касающаяся сразу нескольких библиотек – rake
, rails-observers
, globalize
. Случилось так, что при компиляции ассетов (rake assets:precompile
) на Packer Builder инстансе вывалилась ошибка подключения к БД:
amazon-ebsvolume: PG::ConnectionBad: timeout expired
“Хмн, а накой нам подключатся к базе данных, если мы просто хотим работать с Assets Pipeline на данном шаге? Насоклько я помню, ActiveRecord весьма ленив (в хорошем смысле этого слова) и не затребует подключение до тех пор, пока это дейтсвительно необходимо.” – подумал я. И приступил к глубокому анализу (с прокруткой) цепочки вызовов в бектрейсе ошибки:
rake assets:precompile
загружает окружение Rails.- Rails загружает основную конфигурацию приложения, в котором зарегистрированы ActiveRecord Observers:
# config/application.rb
config.active_record.observers = %i[data_sync_observer]
- Rails загружает
railtie
гемов, включаяrails-observers
гем - Гем
rails-observers
вычитывает конфигурацию и подгружает класс наблюдателя согласно конвенции. DataSyncObserver
класс имеет код, который ссылается на классы моделей:
# app/observers/data_sync_observer.rb
class DataSyncObserver < ActiveRecord::Observer
OBSERVED_CLASSES = [
Sector, Sector::Translation,
Company, Company::Translation
]
...
end
- В
rake
задачах срабатывает автоподгрузка (даже в production окружении) по умолчанию:
# config/environments/production.rb
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.cache_classes = true
# Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers
# and those relying on copy on write to perform better.
# Rake tasks automatically ignore this option for performance.
config.eager_load = true
...
end
Так сделано для увеличения производительности, т.к. часть кода может быть просто не нужна для выполнения конкретной задачи. Следовательно rails
загружает первый попавшийся класс модели Sector
.
- Модель
Sector
декларирует переводимые атрибуты с помощьюtranslates
globalize
макроса:
class Sector < ApplicationRecord
translates :name,
:description
...
end
globalize
вызываетcheck_columns!
для сбора информации о таблице переводов из БД. Бам! “А ведь было уже такое! Опять нетерпеливый Globalize!” – в моей голове всплыли воспоминания.
В надежде понять задумку автора globalize
делать досрочное подключение к БД я побрел на GitHub:
- В “rails asset:precompile attempts to connect to DB because of globalize” Issue признают косяк и говорят что пофиксили.
- “Check if there’s a connection before table_exists?” PR фактически вводит проверку подключения в виде условия с вызовом
connected?
метода. - Там же в комментариях ссылаются на “Support for Rails 5.1” PR, в котором вызов
connected?
заменили на перехват исключенияActiveRecord::NoDatabaseError
. Автор резонно утверждает, что при запуске некоторыхglobalize
юнит-тестов подключения к БД действительно нет. - Однако, вот не задача, ActiveRecord просто пробрасывает оригинальное исключение в случае, когда соединение к БД установить не удалось. Например,
pg
выдаетPG::ConnectionBad
. Вполне логично было бы ожидать какое-то более абтрактное исключение вродеActiveRecord::NoConnectionError
, но увы. Также в “ActiveRecord::NoDatabaseError not raised” Issue поднимает аналогичный вопрос в контексте обработки исключений MySQL и Postgres адаптеров в условиях отсутствия БД. Вот как обрабатывает исключения прилетающие изActiveRecord::ConnectionAdapters::PostgreSQLAdapter
:
def postgresql_connection(config)
...
ConnectionAdapters::PostgreSQLAdapter.new(conn, logger, conn_params, config)
rescue ::PG::Error => error
if error.message.include?("does not exist")
raise ActiveRecord::NoDatabaseError
else
raise
end
end
А так ActiveRecord::ConnectionAdapters::Mysql2Adapter
ищет подстроку "Unknown database"
:
def mysql2_connection(config)
...
ConnectionAdapters::Mysql2Adapter.new(client, logger, nil, config)
rescue Mysql2::Error => error
if error.message.include?("Unknown database")
raise ActiveRecord::NoDatabaseError
else
raise
end
end
Но это уже ньюансы, а сакраментальный там следующий пассаж, подводящий итог всему приключению:
However, that doesn't help Globalize, because there are plenty of connection errors that we don't want to cover up. That's better addressed from a different angle: error handling aside, it's very bad form for a model definition to cause a database connection. Globalize is designed incorrectly: it should not cause a database connection when loading the model definition, and should instead hook the model's load_schema! to run at the right time.
Да, девочки и мальчики, быть нетерпеливым не всегда хорошо.
Поиск решения в процессе…
Благодарочка коллеге Артуру, который навел на первичный GitHub тред по теме.
Ссылки
MySQL::Error
PG::Error
definitions and class generator