Минутка просвещения

Читать в телеге. Когда-то там были посты не только от меня.

Пулл-реквест в Kotlin

Пара недавних постов были подводкой к этому (и еще один грядет). Захотел сделать пулл-реквест в Kotlin, выбрал один из “простых” тикетов. Выкачал репозиторий с GitHub, импортировал в Idea и начались проблемы.

Для регресса нужна установка Java 6. В Readme рекомендуют пакетные менеджеры, аж 3 штуки, но все они устанавливаются только через curl | sh, что опять меня триггернуло. Заленился искать адекватный вариант, в итоге просто отключил флагом gradle.

Попробовал запустить тесты. Один тест не запустился — оказалось, что надо было сначала сгенерировать все тесты, а потом уже запускать compilerTest. Кстати, хрен найдешь этот таск в списке грейдловских. Сгенерированные тесты не прошли, работало больше 2 часов, завершения тестов javascript я так и не дождался. В завалившихся, кроме javascript, были еще тесты с Java 9-15, но даже если запуститься с Java 15, ситуация не изменилась. Причем в репозитории лежит .idea-проект, т.е. подразумевается, что все уже настроено, а по факту — даже просто прогнать тесты уже не так просто, причем проблема не только у меня, а может это и вовсе норма.

Сам проект выглядит не очень ухоженным. Открытых тикетов больше 4 тысяч, веток 3,5 тысячи, TODO в коде 9 тысяч, некоторые пулл-реквесты висят с 2017 года (бывают и просто грустные). В коде встречаются непонятные сокращения, периодически выскакивают java-файлы, форматирование и нейминг неконсистентные, есть даже файлы, названные по идентификатору тикетов в YouTrack. Создается впечатление, что раньше Kotlin был прибит гвоздями к Idea, потому что плагин был в основном коде языка и добавление инспекции могло привести к изменению языка (судя по истории git). Структура проекта вроде понятна, но найти место, где нужно поменять код, не очень тривиально (хотя это скорее я просто затупил). У меня возникло подозрение, что есть код, вообще не покрытый тестами, ну или я их просто не смог найти. У некоторых тестов интересный формат входных данных: котлиновский файл с кучей комментариев для управления проверками (но документации нет).

Выбранный тикет так и не получилось сделать: сначала я долго не понимал, что именно там нужно сделать и где, а потом пришел к выводу, что не факт, что эти изменения вообще нужны. Даже совсем плюнуть хотел на эту затею, но потом вспомнил о баге с манифестом. В нем хотя бы было понятно, что делать и откуда начать. Корневая причина оказалась не там, где я думал, но в итоге со всем разобрался (хотя тесты чужеродно выглядят). Мой пулл-реквест в итоге приняли (achievement unlocked!), но ревьюер проделал больше работы, чем я: я упустил довольно серьезный баг, поэтому не очень собой доволен. А еще TeamCity шлет письма по валящимся билдам, в которые попал мой коммит (хорошо хоть проблемы вызваны не моими изменениями).

СсылкаКомментировать

Переключение на форк

Если возитесь в скачанных исходниках и появилась потребность внести исправления, то не нужно качать заново качать код из своего форка.

Можно просто переименовать origin в upstream, добавить форк в качестве origin и подтянуть информацию из форка.

git remote rename origin upstream
git remote add origin git@FORK
git fetch origin
СсылкаКомментировать

Уровни компиляции JIT

В продолжение темы про работу JVM.

Обычно исходный код компилируется в байткод компилятором языка. Потом проходит его верификация и линковка, после которых он попадает в интерпретатор. И уже интерпретатор переводит его в исполняемый (машинный) код. Хотя можно вообще все скомпилировать заранее, как в C: например, с некоторым ограничениями это позволяет сделать GraalVM.

Есть 4 уровня компиляции:

  1. Интерпретатор
  2. C1 без профилирования
  3. C1 с минимальным профилированием
  4. C1 с максимальным профилированием
  5. C2 со всеми оптимизациями

Интерпретатор выполняет байт-код максимально тупо, “построчно”. В процессе исполнения интерпретатор приблизительно считает, как часто вызывался каждый метод, и после достижения порога он переходит на следующий уровень компиляции — третий. Метод компилируется, в него добавляются счетчики, чтобы получить более точную картину нагрузки. Он исполняется быстрее, чем в интерпретаторе, но все равно медленно. Потом метод попадает на уровень C2. Однако компилятор C2 довольно медленный и не всегда успевает все сделать, поэтому перед попаданием на третий уровень, метод иногда тусит на втором, “в очереди” — исполнение побыстрее третьего, но тут меньше информации. Бывает, что компиляция в C2 невозможна — тогда профилирование выкидывается, и метод работает на первом уровне без профилирования. А иногда JVM решает, что метод очень простой, сразу закидывает его на первый уровень и больше не трогает.

Можно отключить C1 (уровни 1-3) совсем, тогда метод будет попадать из интерпретатора сразу на C2 — и так было до JVM 8: у “серверных” приложений был С2 (медленный старт, но быстрая работа), а у клиентских — C1 (быстрый старт, но медленнее работа).

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

СсылкаКомментировать

Как настроить память для JVM

Некоторые утверждают, что настройки можно определить автоматически, но звучит это сомнительно, потому что JVM — довольно сложная штука.

Память JVM разбита на несколько областей:

  1. Куча (heap). В ней хранятся основные данные и работает сборщик мусора (GC), накладные расходы на который бывают до 10%. Кучу можно зарезервировать сразу всю флагом (-XX:+AlwaysPreTouch), а можно динамически уменьшать и отдавать системе (-XX:UseAdaptiveSizePolicy), поигравшись с настройками зарезервированного запаса, но это работает не с любым GC. Источник проблем в этой области — большие объемы данных.
  2. Метаданные классов. Всякие структуры классов, символы, константы, аннотации и т.п. В поздних версиях разделена на непрерывную область для классов (чтобы можно было сжимать указатели) и область для остальных метаданных. Гадостей в этой области может наделать загрузчик классов, генерирующий кучу классов на каждый чих.
  3. Область JIT-компилятора. Включает в себя скомпилированные куски кода и “арены” компилятора (где хранятся необходимые данные для компиляции: промежуточные представления, статистика и т.п.). Если включена многоуровневая компиляция (по умолчанию это так), то размер области под скомпилированный код увеличивается. Опасность плохих настроек тут в том, что область забьется, и код будет компилироваться и вытесняться по кругу, сжирая процессор. В стабильной системе JIT прогревается на старте и потом почти не вызывается.
  4. Потоки со стеками вызовов. По утверждению докладчика, тут обычно волноваться не о чем.
  5. Таблица символов и таблица строк. Тут тоже редко бывают проблемы.
  6. Внутренняя память. Содержит память, выделенную вне области с GC: ByteBuffer’ы (есть настройка для ограничения) и замапленные в память файлы (нельзя ограничить по памяти). Причем у буферов могут быть кэши в куче, которые еще локальны для потока и никогда не чистятся (но можно ограничить их размер). При аллокации буферов могут возникнуть проблемы с фрагментацией, поэтому стандартный аллокатор имеет смысл заменить, например на jemalloc.

Найти проблемы с утечками памяти можно с помощью async-profiler, который может перехватывать вызовы malloc и определять стектрейс к ним.

В итоге, чтобы определить проблемные места, нужно мониторить все метрики (в том числе включить логи GC и смотреть на метрики операционной системы) и подключать Native Memory Tracking для исследования тяжелых случаев. Посчитать итоговую формулу для контейнера нельзя. Хотя по опыту коллег примерно половина памяти контейнера уходит на память вне кучи.

СсылкаКомментировать

Разновидности event-driven архитектуры

Согласно Фаулеру (видео), существует 3 типа Event-Driven архитектуры:

  1. event notification — уведомление о событии. У меня что-то поменялось, дерни меня синхронно, чтобы узнать что.
  2. event-carried state transfer — передача состояния в событиях. У меня поменялось поле, новое значение такое-то. Или мое состояние поменялось, вот текущее.
  3. event sourcing — нет никаких состояний, есть только набор событий, они являются мастер-данными. Хочешь состояние — либо сам храни, либо вычисляй. Иногда, так и быть, дадим тебе слепок, чтобы попроще было вычислять.
СсылкаКомментировать

Облегчение Font Awesome и задержка из-за CSS

Внезапно обнаружил, что Font Awesome, webdings современного интернета, является встроенным шрифтом в Ubuntu. Однако для других систем его все равно надо подключать. Раздаваемый с CDN CSS, кроме директивы @font-face, содержит еще и кучу лишних предопределенных классов, которые просто проставляют содержимое с нужным юникод-символом.

Но есть и другая неприятная особенность включения стороннего CSS в код страницы: пока он не будет загружен, то страница не будет отображаться. Можете попробовать сами сохранить и открыть в браузере такой HTML:

<head>
<link rel="stylesheet" href="https://httpbin.org/delay/11">
</head>
<body>
<h1>Loaded</h1>
</body>

У меня он открывается примерно через 10 секунд. И такое поведение я наблюдал, когда редактировал сайтик в оффлайн режиме. Поэтому я отказался от внешней зависимости и сохранил Font Awesome на сайт.

СсылкаКомментировать

Генерировать или валидировать?

Как известно, я люблю автоматизировать всякую дичь (например, 1, 2, 3, 4). Один из видов такой дичи — проверка того, что разные части приложения соответствуют друг другу и/или что тестами покрыто все, что нужно. В основном из-за недоверия к кожаным мешкам.

Приведу примеры:

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

Казалось бы, это странные операции. Ведь для проверки покрытия есть специальные утилиты, а такие вещи, как swagger, клиенты для REST легко генерируются. Но, как говорится, есть нюансы.

Средства покрытия не могут проверить все (например, все, что не является кодом) и их точность завязана на строчки кода. 100% покрытия редко достижимы, обычно это в районе 80-90%. Подразумевается, что в эти 10-20% попадает всякая служебная фигня, но никто не гарантирует, что это не окажется ядро системы. Кроме того, редко когда плохое покрытие блокирует релиз, да и мало кто смотрит эти отчеты. А вот если они валят тесты, то это уже повод что-то сделать (можно сделать таск на билде для завала по покрытию, но это обычно гемор).

Генерация — хорошая штука (в конце концов, код — это DSL для генерации машинного кода), но результат все равно придется как-то проверять. Можно довериться разработчикам генераторов, но никто не отменял garbage in → garbage out. Однако не на всякую дичь есть генератор, а хорошо написать свой — не очень тривиально. Сложно учесть все требования и нюансы, надо адекватно встраивать генерацию в процесс сборки, некоторые вещи все равно придется дописывать руками (какие-нибудь описания). Кроме того, страдает гибкость: в сгенерированном API-клиенте уже не затащишь два http-запроса в один метод, в swagger не детализируешь, что там в кишках json, который отдается as is из базы и т.п. Наконец, генерация — процесс с потерей информации, и если нет единого источника правды, который содержит ее полностью, то появляется дополнительная сущность, из которой генерируются остальные.

А написать проверку, что все соответствует друг-другу — довольно просто. И все сущности доступны напрямую из репозитория, ими легко поделиться (если это какой-нибудь контракт, например). Можно не заморачиваться с прокидыванием метаинформации (всякие описания полей) — ее все равно проверить может только человек. Ему правда писать придется чуть больше, но обычно это имеет позволительную цену. Лучше иметь явный тест, чем строчку в чек-листе код-ревью.

СсылкаКомментировать

Устройство компилятора Kotlin

В версии 1.4 JetBrains немного поменяли архитектуру компилятора и про это есть довольно интересный доклад (слайды). В старом компиляторе были немного срезаны углы для быстрой разработки, а в новом все по классике: фронтенд-часть с выходом промежуточного представления (по факту — AST с дополнительной информацией) и бэкенд (который отвечает за генерацию исполняемого кода).

Из интересного:

  • У языка все еще нет спецификации, покрывающей все нюансы языка. Источник правды про его работу — исходный код. Сейчас есть только черновик спецификации. Например, на момент доклада узнать, какие есть варианты смарт-кастов, можно было только из кода (хотя сейчас в спецификации довольно подробно описана куча вариантов, в том числе пример-загадка из доклада).
  • Внутри компилятора есть типы-пересечения (например, общий родитель у Int и FloatComparable<*> & Number), но они невыразимы (надеюсь, что это “пока”).
  • Все if конвертируются в when, но писать лучше все равно по-человечески, ручные микрооптимизации будут только мешать компилятору.
  • В бэкенд-части компилятора есть оптимизации, но они в основном используются для того, чтобы упростить конструкции, чтобы потом JIT’у было их проще понять и оптимизировать. Однако для Native и JS есть еще свои оптимизации, их больше.

Про устройство компиляторов есть еще доклад от небезызвестного Брагилевского (но там про Haskell уже). Я слушал его вживую на конференции, и он был неплох.

СсылкаКомментировать

Лимиты Docker Hub

У Docker Hub есть лимиты на выкачивание образов. Значение по умолчанию — 100 загрузок за 6 часов. Маловероятно, конечно, что будет столько будет закачек, но если нет авторизации и выделенного IP, то исчерпать лимит возможно.

Решения два: либо заплатить бабок, либо качать через кэширующий прокси. Во втором случае нужно будет поменять конфиги и проверять helm-чарты, потому что в них могут быть указаны свои репозитории образов. Например, в bitnami/postgresql нужно заменить docker.io на свою прокси аж 3 раза.

СсылкаКомментировать