Хроника перевода gem'а на Ruby 3.0

На новогодние праздники я традиционно беру неделю отпуска и, как правило, не выдерживаю столько свободного времени сразу и нахожу что поковырять и попилить. В этот раз я уделил время своим проектам - Dynamoid и RubySpec. Всегда надо что-то доделать, дополировать или дописать в документации. Давно пора было выкатывать новую версию Dynamoid ведь последний релиз вышел еще полгода назад, а обычно версии выходят каждые 3-4 месяца.

На днях вышел Ruby 3.0 да и свежая версия Rails (6.1) только месяц как появилась. Хотелось быстренько позапускать на них тесты и добавить в Changelog строчку о поддержке и совместимости. Новые версии Ruby обычно заводятся без проблем ведь breaking changes довольно редки. С Rails возникали сложности, но постепенно Dynamoid все меньше и меньше зависит от Rails.

Ничего не предвещало беды и я планировал быстро все закончить - добавить новые версии в матрицу конфига CI, дождаться пока пройдет билд и обновить Changelog. Но меня ждали сюрпризы.

Проблема с keyword arguments

Сразу же всплыла проблема с keyword arguments. В Ruby 3.0 сломали обратную совместимость и немного изменили этот механизм. Это исчерпывающе описано в статье The Delegation Challenge of Ruby 2.7. К сожалению просмотрев материал наискосок несколько раз я так до конца и не разобрался в теме. За что пришлось заплатить временем и понаступать на описанные там грабли. После нескольких заходов у меня наконец сложился пазл и родилось финальное решение.

Можно сколько угодно упрекать Matz’а в костыльности подхода с новым методом ruby2_keywords, но это закрывает бОльшую часть вариантов использования. К сожалению этот подход не работает для Dynamoid. Интересно почему Matz не посмотрел в сторону аннотаций в комментариях, как это варят в Java?

Проблема с RSpec

Также выяснилось, что RSpec не готов к переходу на Ruby 3, хотя deprecation warning‘и появились еще в Ruby 2.7 и времени хватало.

Один тест в Dynamoid упал на Ruby 3.0 и виновником оказался rspec-mock. Ошибку легко воспроизвести комбинацией вызовов receive и and_call_original:

expect(object).to receive(:foo).and_call_original

Более полный пример:

it 'reproduces the issue' do
  object = Object.new
  def object.foo(a:, b:)
    [a, b]
  end

  expect(object).to receive(:foo).and_call_original
  object.foo(a:1, b: 2)
end

Это падает где-то в недрах rspec-mock:

ArgumentError:
  wrong number of arguments (given 1, expected 0; required keywords: a, b)
# ..._spec.rb:428:in `foo'
# .../gems/3.0.0/gems/rspec-mocks-3.10.1/lib/rspec/mocks/message_expectation.rb:101:in `call'
# .../gems/3.0.0/gems/rspec-mocks-3.10.1/lib/rspec/mocks/message_expectation.rb:101:in `block in and_call_original'
# .../gems/3.0.0/gems/rspec-mocks-3.10.1/lib/rspec/mocks/message_expectation.rb:740:in `call'
# .../gems/3.0.0/gems/rspec-mocks-3.10.1/lib/rspec/mocks/message_expectation.rb:572:in `invoke_incrementing_actual_calls_by'
...

Этот конкретный баг уже починен вот здесь. Вчера. Хотя issue открыли еще год назад. Парень из core team пояснил в комментариях, что не хватает тестов на сам RSpec, которые бы проверяли работу с keyword arguments. А core team занимается следующим мажорным релизом RSpec 4. Как это возможно, спросите? Я тоже не понимаю.

Писали в issues и о других местах, где RSpec падает на Ruby 3.0. Остается только ждать релиза с фиксами или даже исправлять самому.

Проблема со старыми версиями Rails

Последние версии Rails 6.0.x и 6.1.x без проблем взлетели на Ruby 3.0. А вот с Rails 5 уже сложнее. Rails 5.2 и Rails 5.1 не работают на Ruby 3.0 (issue).

Согласно политике поддержки core team Rails исправляют баги только в текущей минорной версии (Rails 6.1). Правки к остальным версиям примут только в особых случаях. Поэтому если Rails 5.2 и Rails 5.1 не работают на Ruby 3.0, то уже ничего не поделаешь. Учитывая, что минорные версии Rails выходят в среднем раз в год, выходит, что баги в текущей версии будут чиниться только один год пока не выйдет следующая минорная версия.

Я бы упрекнул core team Rails только в том, что нет явной таблицы совместимости версий Rails и Ruby. Единственное что поможет - это на каких версиях запускают тесты на CI. До Rails 6.1 использовали TravisCI (конфига) и затем перешли на https://buildkite.com/rails/rails.

Проблема с Rails 4.2

Заодно решил вернуться к Rails 4.2 и перепроверить с какими версиями Ruby Rails совместимы. До сих пор Rails 4.2 гонялся на CI только на версиях Ruby с 2.3 по 2.6. Возможно я добьюсь, чтобы Rails 4.2 завелось и на поздних версиях?

На Ruby 2.7 Rails 4.2 сломалась. В Ruby удалили метод BigDecimal.new, который использовали в Rails. Так как Rails 4.2 уже не поддерживалась, это не исправили. Выяснилось, что это решается без monkey-patch‘а. BigDecimal вынесли в самостоятельный default bundled gem, который поставляется вместе с Ruby по умолчанию, но релизится по независимому графику. Нашел версию bigdecimal, в котором метод BigDecimal.new еще не удалили, и подключил вместо штатной версии.

Согласно документации нужная версия bigdecimal (без breaking changes, 1.4.x) работает только до Ruby 2.6. Но Rails 4.2 завелась и на Ruby 2.7, в том числе. А вот на Ruby 3.0 уже вылетает на уровне бинарников:

dyld: lazy symbol binding failed: Symbol not found: _rb_check_safe_obj
  Referenced from: .../lib/ruby/gems/3.0.0/gems/bigdecimal-1.4.4/lib/bigdecimal.bundle
  Expected in: flat namespace

dyld: Symbol not found: _rb_check_safe_obj
  Referenced from: .../lib/ruby/gems/3.0.0/gems/bigdecimal-1.4.4/lib/bigdecimal.bundle
  Expected in: flat namespace

Любопытна ситуация с JRuby. Dynamoid поддерживает JRuby, но запускаем тесты только на текущей версии, которая соответствует Ruby 2.6. Поэтому проблемы с Rails 4.2 пока нет и не нужно устанавливать нештатную версию bigdecimal. Так как bigdecimal содержит native extension - его нельзя завести на JRuby. Для удобства и совместимости файлов *.gemspec/Gemfile пользователям JRuby нужна версия gem‘а на RubyGems и для JRuby. Хотя бы в виде заглушки.

Этому посвящен отдельный тикет на багтрекере Ruby. Любопытно, что Ruby core team игнорирует его последние 8 лет. Не ответили ничего по существу ни лиду из JRuby 8 лет назад, ни лиду из TruffleRuby год назад. Более того, они позволяют себе следующее:

So, No. Please drop f*cking insane idea. It doesn work at all.

Чтобы прекратить поддержку какой-либо версии Rails надо анализировать статистику использования или хотя бы скачиваний с RubyGems. Но даже не глядя на числа очевидно, что Rails 4.2 используется в проектах. Не так давно даже выкатывали релиз с security фиксом. А с год назад видел в одном проекте Rails 4.2 на продакшене. И не смотря ни на что команда не спешила обновлять Rails.

PS

На то, чтобы разобраться в теме, ушел день. И в результате Dynamoid заводится на Ruby 3.0 и на Rails 6.1. Также начал тестировать gem с Rails 4.2 на Ruby 2.7 и JRuby. И хотя разборки с keyword arguments я откладывал до последнего момента все-таки пришлось погружаться.

Ситуация с поддержкой старых версий Rails конечно неожиданная и сильно меня удивила. Желающих законтрибутить в Rails много и я думал, что у core team хватает ресурсов на поддержку старых версий. У Ruby core team получается поддерживать релизы по 3-4 года, а у Rails core team - только 1 год.