Переписи Campfire Once: что показали AI-агенты

DHH, создатель фреймворка Ruby on Rails, решил переписать Campfire Once с Rails на Rust. Или скорее поручил это AI-агенту, так как сам не любит читать и писать код на Rust. После этого были добавлены переписи на других языках — Elixir и Go.

Это любопытный случай использования AI-агентов без глубокого анализа кода тем, кто при этом имеет богатый опыт программирования. И результаты… не очень воодушевляют, если надеялись полностью отказаться от чтения кода или вообще от мышления в ближайшее время.

Проблема в деталях: недостаточно специфичные указания

Переписи содержат множество проблем, но начнём с функциональных различий, так как они хорошо иллюстрируют одно важное наблюдение: если указание недостаточно специфично, множество решений становятся подбросом монеты. Это происходит потому, что на множество вопросов не существует единственно правильного ответа. Нужно ли сохранять обратную совместимость? Важнее ли минимальная задержка или максимальная пропускная способность? Сколько памяти может использовать система под нагрузкой? Приемлемо ли потеря уведомлений о новых сообщениях после краха?

Может, эти вопросы кажутся неважными, но LLM неявно ответит на них при реализации того, что считает вашим требованием.

Если изучить переписи детальнее, сразу видно, что они по-разному подходят к обратной совместимости и другим ограничениям. Версия на Rust не сохраняет 100% обратную совместимость — например, удаляет CSRF-токены для упрощения кэширования. Также заменила Redis на очереди внутри процесса, в том числе для уведомлений. Переписи на Elixir намного ближе к оригиналу Rails. Это уже делает сравнение практически бесполезным, так как эти различия независимы от языка. AI-агент услышал «переписать на Elixir» и не выбрал 100% обратную совместимость из-за особенностей этого языка.

Проблемы в коде

Если посмотреть на сам код — да, мы знаем, что больше не должны его читать — сразу заметны серьёзные проблемы. Люди жаловались, что версия на Elixir имеет один процесс, последовательно обрабатывающий все SQL-запросы, даже если это операции чтения, которые можно выполнять одновременно. Справедливая критика. DHH похоже думает, что для Elixir не очень хорошо выглядит, если AI-агент не может писать производительный код. Но если посмотреть на версию Rust, картина не совсем ясная.

В версии Rust некоторые операции с базой данных работают не асинхронно, что в отдельных случаях может быть хуже. В Rust при использовании async runtime планирование кооперативное, а не вытесняющее. Если задача не передаёт управление, никакая другая задача не может запуститься на том же рабочем потоке. На практике это означает, что время выполнения задачи должно быть как можно короче. Например, при запросе к БД длительностью 100 мс все остальные задачи ждут, пока текущая задача ожидает ответа. Идеально использовать асинхронную I/O-операцию, которая передаст управление runtime'у до получения ответа от БД.

В переписи на Rust некоторые запросы выполняются на рабочих потоках, другие — как блокирующие операции в асинхронных задачах. То же с блокировками. При использовании async runtime безопаснее всего использовать асинхронные блокировки вроде tokio::sync::Mutex. Синхронная версия допустима, если уверены, что блокировка держится очень недолго. Но если держать синхронную блокировку 10 мс, все задачи на одном потоке ждут, пока сама блокирующая задача ничего не делает. Поэтому нет, кодирование вероятно не «решено», и знать, что делаешь, всё ещё нужно.

Бенчмарки и реальность

Выяснилось, что упрощённые бенчмарки — тоже не идеальны, так как измеряют только пропускную способность, игнорируя другие свойства системы. Например, Зак Дэниелс измерил частоту доставки уведомлений о новых сообщениях под нагрузкой и обнаружил 1% успешной доставки в версии Rust при высокой нагрузке. Не очень приятный результат. Бенчмарки — это сложная штука.

Но даже улучшенный бенчмарк может не тестировать именно то, что нужно, в зависимости от характеристик системы. При тестировании можно хотеть подчеркнуть разные свойства под нагрузкой.

И DHH, и Зак использовали closed-loop бенчмарки — они проверяли «сколько запросов система может обработать за определённое время?». Тест использовал N клиентов, каждый отправляющий новое сообщение сразу после получения ответа на предыдущее. Однако в реальности увеличенная нагрузка часто приходит от большого количества пользователей, одновременно выполняющих одно действие и не ждущих, пока другие закончат. В этом случае предпочтительнее constant arrival rate — отправлять запросы с постоянной скоростью, а не делать её зависимой от того, насколько быстро система может ответить. Если проверяются надёжность доставки событий, хорошо бы сравнивать одинаковое количество событий.

Если посмотреть на результаты: Rust доставил 1% уведомлений из 6–7k, а Elixir доставил 100% из ~1.7k. Это показывает, что версия на Elixir лучше управляет обратным давлением, но это верно только пока количество запросов не перегружает систему.

Trade-offs в программировании

Отойдём на минуту от бенчмарков и поговорим о компромиссах, потому что программирование — это всё о trade-offs. Да, существуют ситуации, где решение явно лучше без минусов, но это редкость, особенно в сложных системах, которые должны быть надёжными.

После 1% доставки в версии Rust множество людей из сообщества Elixir пришли к скорым выводам, не попытавшись понять, почему это происходит. Общее мнение: Elixir просто лучше для конкурентности! Планировщик в Rust кооперативный — как вообще можно работать? Elixir хорош, и его успешно использовали в production, но это не панацея. Да, Elixir (и другие языки на BEAM) отлично подходят для конкурентности, и писать конкурентный код в Elixir обычно легче, чем в Rust, но это стоит отсутствия низкоуровневого контроля, большего потребления памяти и часто меньшей скорости. Есть причина, почему существует rustler. Объявлять превосходство языка на основе одной метрики без понимания корневой причины может быть вводящим в заблуждение.

Что изменилось при более внимательном анализе

Помните, как closed-loop стресс-тест показал только одно свойство системы? Rust обработал ~4 раза больше запросов и, таким образом, должен был передать больше событий клиентам через WebSocket. Был переснят тест с постоянной частотой доставки, используя версию DHH's Rust и Zach's Elixir с различными исправлениями. При 100 POST/s клиенты получали ~14% событий в Rust и ~60% в Elixir. Лучше, верно? Не совсем! На этом уровне трафика Rust не имел HTTP ошибок. Elixir timeout'нулся на ~23% POST запросов. А что с задержкой? Максимальная задержка доставки события была близка к 180s. В Rust, когда доставки отстают, клиенты отключаются. При переподключении браузерный клиент загружает последние сообщения, что в значительной степени делает упущенные события ненужными.

Что лучше с точки зрения UX: клиент молча переподключается в фоне и загружает обновления, или ждёт уведомления о новом сообщении 3 минуты? Это снова показывает, что одна метрика не рассказывает всю историю.

Понимание trade-offs: пример с broadcast каналом

Знаете ли, почему версия Rust теряет столько сообщений под нагрузкой? Она использует tokio::sync::broadcast канал для трансляции событий подключённым клиентам. Событие о новом сообщении в чате может потребоваться отправить нескольким соединениям, так что это имеет смысл. Одно из свойств broadcast канала — что он ограничен установленной ёмкостью. Если получатель не может обрабатывать сообщения достаточно быстро, получатель получит ошибку RecvError::Lagged (подробнее см. в документации о lagging). Ёмкость broadcast в переписи Rust была установлена на 256. В Elixir процесс GenServer обрабатывает доставку событий, и по умолчанию почтовые ящики GenServer не ограничены. Однострочное изменение в Rust:

- stream_capacity: 256
+ stream_capacity: 16384

увеличивает частоту доставки при 100 req/s с ~14% до ~90%. Теперь лучше, чем Elixir, верно? Не совсем. Эта версия на самом деле хуже, потому что лучше быстро отказать и заставить клиента переподключиться, чем обрабатывать вещи чрезвычайно медленно. Что ещё изменилось после повышения ёмкости? Максимальная задержка доставки события выросла с 11s до >130s, похоже на поведение Elixir, что я считаю явно хуже, чем отключение отстающих клиентов. По мне, даже 11s — слишком много, и если получатель не может передать событие быстрее, лучше его отбросить. Это показывает, как важно устанавливать разумные ограничения в системе, и что на самом деле Elixir не автоматически решает все проблемы конкурентности из коробки. Также, если сам не установишь ограничения, столкнёшься с внешними лимитами. Во время стресс-теста 100 req/s версия Elixir достигла 1.8GB потребления памяти. Ещё есть о чём подумать: лучше потерять несколько сообщений или получить OOM? Trade-offs везде. Elixir хорош, но не волшебно решит все твои проблемы. Независимо от используемого языка, нужно думать о режимах отказа и trade-offs.

Выводы

Что мы выучили? Критически мыслить всё ещё нужно. Важно знать, что делаешь. Не спешить с выводами на основе одной метрики. Если нужна надёжная система, нужно знать, когда отказать. И ещё: бенчмарки — это сложно.