Big Flatmappa
335 subscribers
8 links
Большой русский ФП кот Схлопа

Чат: @bigflatmappachat
Download Telegram
Channel created
В экосистеме Cats Effect есть утилита Hotswap, предназначенная для управления жизненным циклом заменяемых "на горячую" ресурсов через общий ресурсный скоуп.

К сожалению, она непригодна для конкурентного использования: внутренняя стейт-машина не защищена от конкурентных попыток свопа, а релиз заменяемого ресурса не отслеживает его использование другими файберами, что легко приводит к утечке.

Для trace4cats понадобилась подобная штука, и мы с Chris Jansen сделали конкурентную обёртку над Hotswap, лишённую перечисленных недостатков.

При этом:
- конкурентные свопы (swap) блокируют* друг друга, защищая внутренний Hotswap;
- каждый доступ (access) к текущей версии ресурса контролируется отдельным ресурсным скоупом: если исполнение хотя бы одного файбера находится внутри такого скоупа, ресурс гарантированно не будет финализирован при свопе — своп заблокируется, пока ресурс не будет освобожден всеми пользователями;
- своп не блокируется конкурентными чтениями во время аллокации нового ресурса: как только новый ресурс создан, он может быть прочитан из других файберов;
- конкурентные доступы к ресурсу не блокируют друг друга и практически никогда не блокируется свопом, но всё же есть небольшой шанс обратиться к ресурсу именно в момент перед перезаписью ссылки на него, но не успеть захватить блокировку до того, как это сделает финалайзер в свопе.

* здесь и далее имеется в виду семантическая блокировка

Запаблишили пока отдельной либой под Scala 2.12, 2.13 и 3, правда только для CE3 🤷🏻‍♂️ Может быть в будущем оно переедет в cats.effect.std. Критика Hotswap отражена в этом issue.

Какие уроки я вынес для себя из этой задачки:
- сложно понять cancelation и корректно учесть в коде все ситауции с отменой;
- написать такой же хороший код для CE2 сложно: там аллокация ресурса в принципе неотменяема, поэтому нельзя использовать Resource для содания и композиции отменяемых скоупов, а в CE3 есть Poll (см. разницу между withPermit в CE2 и permit в CE3 на Semaphore);
- написать вообще хороший конкарренси код с первого раза невозможно: мы написали с пятого — и то между 2-й и 3-й попытками прошло много времени, прежде чем мы обнаружили проблему.

Ещё из приятного: свой первый кросс-релиз под Scala 3 сделал за несколько минут.
До сих пор считаю doobie самой хорошей библиотекой для работы с JDBC, при всех минусах самого протокола. Правда, из её дизайна следует ряд проблем, из-за которых с её помощью довольно сложно сразу начать писать правильный tagless final код для транзакционной бизнес-логики.

Во-первых, для описания действий с базой используется Free-монада ConnectionIO, умеющая довольно многое (весь набор JDBC-операций + инстансы сильных тайпклассов из CE), но не умеющая таскать произвольный контекст, как это умеют ZIO, Env или ReaderT. Кроме того, ConnectionIO вшита в Transactor, выполняющий её интерпретацию в произвольный эффект и управляющий транзакцией.

Во-вторых, достаточно полезная трейсинговая информация о выполнении SQL-запросов может быть передана только в LogHandler - сайд-эффектящий обработчик вида LogEvent => Unit, что при использовании "в лоб" затрудняет идентификацию этих событий в логах.

В-третьих, правильно дополнить транзакционную логику какими-нибудь посторонними (пусть и неоткатываемыми) эффектами в CE2 было непросто, а в CE3 стало очень сложно (возможно, напишу об этом отдельно).

В-четвертых, писать код с конкретной монадой — не наш путь. Особенно, если при попытке решить часть предыдущих проблем вместо ConnectionIO приходится использовать что-то ещё более сложное.

Хочется простого — работать с абстрактным DB-эффектом с поддержкой контекстного логирования и трассировки, ограничивая его способности только необходимыми баундами и алгебрами с бизнес-логикой. Главный вопрос — как?
Для решения проблем из предыдущего поста я пробовал разные подходы в течение нескольких лет, но в начале прошлого года мои идеи окончательно кристаллизовались и попали в tofu.

Итак, решение было таким:
1. Ввести абстрактный DB-эффект.
2. Создать фасад над транзактором, поддерживающий работу с произвольным DB-эффектом.
3. Ввести тайпклассы для лифтинга в DB-эффект операций с БД (описываемых в ConnectionIO) и дополнительных нетранзакционных действий (описываемых в F).
4. Использовать выше обозначенные сущности для описания транзакционной логики в TF-стиле.
5. Выбрать достаточно мощную монаду — замену для ConnectionIO, для которой можно было бы вывести все нужные инстансы и реализовать соответствующие лифтинги и транзактор с поддержкой типизированного контекста.
6. Научить LogHandler работать с контекстным логером.

С пп. 1–4 было всё понятно, сложность представляли остальные два пункта.

На выбор новой DB-монады меня подтолкнуло то, что ConnectionIO в CE2 имела инстанс LiftIO, а сама IO тогда была неким универсальным способом интеропа между любыми библиотеками эффектов. Казалось, что ConnectionRIO[R, *] (обычный ReaderT поверх ConnectionIO) решает все проблемы: контекстные эффекты легко лифтятся в неё через ряд преобразований, написать аналог Transactor, умеющий интерпретировать ConnectionRIO в такой же контекстный эффект, тоже не очень сложно. Для LogHandler была написана специальная обёртка, позволяющая через UnliftIO и Embed встроить сайд-эффектящий, но не теряющий контекст хэндлер в любой DB-модуль.

С этим решением в прод поехали несколько моих сервисов и проекты других компаний, где используют tofu.

Сюрприз прилетел весной этого года, когда я начал наконец погружаться в CE3 и соответствующие изменения в doobie. Оказалось, что из-за общего редизайна залифтить в ConnectionIO стало возможно только Future — а получить её в чистом виде, вне контекста F, достаточно проблематично. Пришлось искать другой способ выражения DB.

Даже не помню, как я к этому пришёл, но решил рассмотреть континуальное представление ConnectionIO как альтернативу ConnectionRIO, что-то типа:

type DB[F[_]] = [x] =>> ConnectionIO ~> F => F[x]

Этого оказалось достаточно, чтобы залифтить не только ConnectionIO, но и любой произвольный эффект F без промежуточных конверсий и явного извлечения контекста. Более того, такая форма позволяет почти бесплатно выводить большинство инстансов для DB через инстансы для F. Можно выкинуть огромную простыню дериваций лифтингов, которые я когда-то написал для ConnectionRIO.

Больше всего я опасался невозможности выразить транзактор с сохранением API и поддержкой стримов. Но к счастью, doobie.Transactor задизайнен достаточно хорошо, чтобы это стало осуществимым — интерпретация и транзакционная стратегия легко отделимы друг от друга.

Всё получилось даже на CE2. Жаль, я не додумался до континуального представления раньше. Спасибо дедушке Йонеде за нашу прекрасную молодость.

Отправил сегодня PR в tofu. Там есть небольшой пример использования (вместо доки), он почти не изменился — наоборот, кое в чём стал даже проще. В целом этот подход я воспринимаю как гораздо более правильный и чистый. Надеюсь, в последний момент не вскроется что-нибудь критичное. Что касается LogHandler — работа с ним стала чуть проще, за счет инстанса UnliftIO[DB], но когда мигрируем tofu на CE3 — окончательно с ним разберусь.

Кстати, по миграции на CE3 стало заметно, как Rob Norris забросил развитие doobie и посвятил все силы skunk'у. Последний я пока не распробовал — смущает собственная имплементация Postgres-протокола. Впрочем, слышал от уважаемых юзеров хорошие отзывы. Надо будет затащить куда-нибудь и сравнить их эргономику. Как минимум, трейсинг в skunk уже вшит, это обнадеживает.
Хех, покопался в истории чата @tofu_ru — оказывается, мы ещё 2 апреля обсуждали с @odomontois возможное решение через континуэйшены. Но тогда мне этот вариант энкодинга показался сложным в имплементации всей нужной обвязки, и я его сам отбросил, так толком и не попробовав.

Хорошо, что я об этом успешно забыл и не побоялся вчера запилить всё с нуля.
Вот и закончилась полугодовая эпопея миграции одной библиотеки на Cats Effect 3, а вдогонку — и на Scala 3. Вчера отправил в Sonatype больше 50 артефактов (160, если считать кросс-билды) и ещё несколько образов на Dockerhub. Спешу поделиться ощущениями, пока они ещё свежи.

Миграция Typelevel экосистемы на CE3 до сих пор буксует. Многие базовые библиотеки типичного CRUD-стэка либо только-только разродились стабильными релизами (http4s), либо находятся на этапе RC и даже майлстоунов (doobie, tapir), либо вообще ещё не имеют полной поддержки (tofu).

Просто напомню, что с момента стабильного релиза CE3 прошло больше 4 месяцев, а RC были доступны задолго до этого.

Я наблюдал за миграцией многих библиотек, и основные проблемы, которые я заметил, были связаны с использованием плохих абстракций (привет ConcurrentEffect) и смешным игнорированием тотала Reader-паттерна. Для многих стало сюрпризом, что теперь нельзя взять и запустить F[A] просто так, без какого-то контекста и рантайма. А когда это всё же удаётся, не всегда получается сделать это правильно (конструктор Dispatcher'а создает его как ресурс, теряющий локальные модификации контекста и отменяющий все запущенные файберы по выходу из use-скоупа). С другой стороны, в официальном около-CE-стэке никогда и не было всех необходимых тайпклассов для полноценной работы с контекстными эффектами.

Очень жаль, что оригинальный (откровенно плохой) дизайн первых Cats Effect сыграл злую шутку с tagless final и сделал для его похорон гораздо больше, чем ZIO. Остается лишь процитировать классика — он оказался чертовски прав. Ещё печальнее, что даже после трёхлетней работы над новым дизайном там остались изъяны, которые теперь уже никогда не будут устранены в угоду обратной совместимости. Неприятно было наблюдать в dev-каналах гиттера и дискорда Typelevel, как неплохие идеи зарубили аргументами о том, что юзеры болезненно воспринимают любые изменения, требующие что-то переписать. К слову, сами авторы надеются, что CE4 никогда не появится (т.е. всё отлито в граните и кардинально уже не изменится). С таким подходом поезд ZIO будет уже не догнать.

Тяжелее всего миграция далась библиотекам, которые имеют кучу интеграций и обязаны их поддерживать на своей стороне. Например, в trace4cats количество блокеров, не включая транзитивных, в начале пути превышало десяток. Просто дождаться от каждого из них хотя бы майлстоуна заняло 3 месяца. Ещё столько же времени ушло на их стабилизацию. Это стало основной мотивацией для распиливания монорепы аж на 20 репозиториев, чтобы иметь возможность релизить их независимо.

Другой пример — tapir. Они всё ещё сидят в монорепе и несколько месяцев не могли сделать первый M-релиз из-за одного единственного блокера — catbird. Им раньше занимался Трэвис, но потом он самоустранился и отдал миграцию в руки Typelevel-сообщества. Бесхозный статус сильно замедлил переход на CE3 и сделал эту библиотеку блокером для tapir'а и (транзитивно) для нас.

Но, несмотря на все сложности, я очень доволен результатом в trace4cats: отрефакторили многие вещи, исправили кучу багов, а фиксы бэкпортировали в CE2-совместимую версию, воплотили новые идеи, реализовать которые под СЕ2 было бы достаточно сложно, а в СЕ3 оказалось возможно и очень интересно.

В общем, теперь всё готово, можно брать и тащить в прод!
👍1
Да, в заключение добавлю про Scala 3. Ишшу на добавление кросс-билда trace4cats я завёл ещё в январе — на месяц раньше, чем для миграции на CE3, но всерьёз этим мы начали заниматься совсем недавно. И здесь впечатления гораздо приятнее.

Надо отдать должное команде Dotty за титанический труд, благодаря которому весь старый код практически без изменений скомпилировался под Scala 3. Да, пришлось выкинуть bm4 и переписать kind-projector'ные полимфорные лямбды, но эти мелочи к самому языку не относятся и скорее являются проблемами только при кросс-билде, а не полноценном переходе. Было ещё несколько совсем неожиданных ошибок с выводом некоторых имплиситов поверх ZIO, но это просто баги компилятора, которые обязательно починят позже (надо не забыть зарепортить их).

А вот портирование макросов — это настоящая беда. Благо, своих у нас нет, зато используются сторонние дериваторы на magnolia и кастомных макросах. Пришлось (как минимум — на какое-то время) заменить их на вручную написанные инстансы.

Но и из этого удалось вынести кое-какую пользу. Например, при тестировании оказалось, что автовыводимые avro-кодеки в vulcan (внезапно) генерируют схемы с разным порядком сумм на Scala 2 (в оригинальной имплементации на magnolia) и на Scala 3 (пока что только в POC-имплементации на shapeless, т.к. magnolia — RIP). Зато вывести их руками для весьма развесистой модели заняло полчаса, и есть уверенность, что схема будет стабильной.

Были и пара случаев, когда я обнаруживал проблемы в версии какой-нибудь либы под Scala 3, бежал делать PR, но пока его принимали, мержили и релизили, я перегорал от ожидания и просто выпиливал эту зависимость, заменив чем-то попроще. На самом деле очень рад, что из-за косяка в кросс-билде scalapb мы вообще избавились от protobuf в одном модуле и избежали использования scalapb-json4s (от кодеков которого остались ужасные впечатления с какими-то жуткими java-флэшбэками).

Буквально за пару недель удалось всё сделать и даже не задерживать давно запланированный релиз trace4cats под CE3. Мне Scala 3 тащить в прод пока негде, но один из помощников в миграции обещал внедрить новую версию у себя at $WORK, где они уже всё остальное перевели (какие же они молодцы).
Scala Steward — одна из моих самых любимых утилит в Scala-экосистеме и лучший помощник любому проекту в борьбе с техдолгом в части обновления зависимостей. Он умеет не просто бампить версии плагинов и либ, но и прогонять sbt-таски после этого: переформатировать проект после обновления scalafmt, прогнать scalafix'ы и даже перегенерировать yaml'ы в .github/workflows — этим он выгодно отличается от аналогов типа Renovate именно для Scala-проектов.

Но одной фичи там очень долго и очень сильно не хватало — поддержки проектов с несколькими активными бранчами. В http4s их, например, аж четыре. Какое-то время назад и мне пришлось столкнуться с необходимостью поддержки двух активных версий библиотеки после сплита Typelevel-экосистемы на CE2/CE3.

И наконец эту фичу добавили — теперь Стюарду можно указать, в какой бранч репозитория следует присылать PR'ы. С этого дня жизнь мэйнтейнеров станет немного проще.

P.S. Напомню, что Стюард прикручивается и к приватным репозиториям, и не только на github. Так что если вы в своих проектах до сих пор бампите версии руками раз в 3 месяца (а потом тратите ещё день, чтобы проект скомпилировался после массового апгрейда стэка), бегом настраивать Стюарда!
В Scala 2 никогда не было обратной совместимости между мажорным версиями, а до 2.10 — и между минорными. Напомню, что применяется схема версионирования epoch.major.minor, как и у Java.

Многих эта несовместимость отталкивала от использования Scala в долгосрочных проектах. Неумение или нежелание работать с техдолгом могли всего за несколько лет привести к полному протуханию кодовой базы и невозможности обновить стэк. Я видел энтерпрайз, навечно застрявший на 2.11 без какой-либо надежды на миграцию хотя бы на 2.13, не говоря уже про 3.0.

Повышение версии компилятора и стандартной библиотеки в крупных проектах порой требовало титанических усилий. Например, команда Apache Spark — в своё время одного из главных бустеров популярности Scala — потратила на миграцию на 2.13 больше двух лет и завершила её на днях с выходом версии 3.2.

С самого начала становления экосистемы Scala перед авторами библиотек возник вопрос — как версионировать свои артефакты, чтобы обеспечить их корректное использование на разных версиях компилятора, не превратив управление зависимостями в ад. Появилась концепция cross-building'а — сборки и публикации Maven-артефактов одной версии с разными суффиксами в имени, обозначающими бинарную совместимость с конкретной версией Scala, например org.typelevel:cats-core_2.13:1.0.0. При всех минусах отсутствия обратной совместимости кроссбилдинг был приемлемым решением, а благодаря поддержке в sbt — и весьма удобным.

Команда разработки Scala 3 совершила маленькую революцию — ввела схему версионирования major.minor.patch и гарантировала обратную совместимость для всех будущих версий 3.x.y. Суффикс в именах maven-артефактов сократился до _3. Старожилы Scala-сообщества сначала не верили в избавление от необходимости кроссбилдить библиотеки под разные субверсии Scala 3, а потом не могли сдержать радости. Ликование усилилось с выходом Scala 3.1, когда каждый мэйнтейнер смог лично убедиться, что библиотеки, опубликованные для 3.0, успешно работают с новым компилятором.

Праздник длился всего несколько часов, пока Seth Tisue не запостил трэд, в котором разъяснил все последствия этих изменений. Scala 3 гарантирует обратную совместимость, но не гарантирует, а точнее, вовсе не поддерживает forward-совместимость, во всяком случае пока. Попытка использовать артефакт, полученный от старшей минорной версии компилятора, приводит к ошибкам в compile-time на младшей. Пока что нет и поддержки в sbt, чтобы обнаруживать это во время разрешения зависимостей. А это значит, что библиотекам не сто‌ит спешить с переходом на Scala 3.1, если они не хотят оставлять своих пользователей, по каким-то причинам застрявшим на предыдущей версии компилятора, без дальнейшей поддержки. Ведь теперь нельзя скроссбилдить и опубликовать артефакт под разные версии Scala 3.x, как это было раньше.

На самом деле эта проблема уже много лет существует в Java-экосистеме, где авторам библиотек приходится сидеть в лучшем случае на jdk8. Она же всегда существовала, но оставалась малозаметной и в Scala.js. Но мэйнстримовое Scala-сообщество оказалось к ней не готово. Кто-то вообще её не увидел, кто-то начал сглаживать углы, кто-то — откатывать влитые PR'ы, кто-то — откровенно саботировать любые попытки договориться об общих правилах.

В данный момент дискуссия по существу переехала на Github в комментарии к одному из PR'ов. Наиболее адекватно выглядят аргументы Ross Baker — основного мэйнтейнера http4s, выпустившего за последние месяцы ряд CVE-патчей сразу к четырем активным веткам этого проекта. Он описал разные подходы к решению проблемы, из которых мне ближе всего последний — билдить проекты на разных субверсиях Scala 3, но публиковать пока только под 3.0.2.

P.S. Пока я писал этот пост, вышел официальный анонс релиза Scala 3.1, в котором рекомендуют тот самый Scenario C, предложенный Россом, и обещают сделать всё возможное для добавления forward-compatibility в Scala 3.2.0. Вот это поворот! Надеюсь, у команды компилятора всё получится.
👍2