Предисловие
В начале я хотел бы сказать, что описанные в статье практики очень плохи и неправильны (с тех пор мы добавили надёжные юнит-тесты и интеграционные тесты, а также систему алертов/логирования), что их следует избегать и в первую очередь это человеческие ошибки, которые задним умом кажутся очевидными.
Описанные в статье события произошли в условиях сильного дефицита времени на очень ранних этапах работы компании (первые несколько недель). По большей мере я публикую это как забавную историю с уникальными условиями, связанными с воспроизводимостью бага в продакшене (тоже из-за нашей собственной глупости). Пожалуйста, помните об этом, читая текст.
Впервые мы включили монетизацию нашего стартапа в мае прошлого года. Мы не ожидали многого, но были приятно удивлены тем, что меньше чем за час после запуска у нас появился первый клиент. Это был волшебный момент. Мы отправили ему благодарность, произнесли всей командой тост и пошли спать, ведь на подготовку мы потратили две ночи.
Проснувшись утром, мы увидели больше сорока уведомлений Gmail с жалобами пользователей. Похоже было, что за ночь поломалось всё. Никто из пользователей не мог подписаться. И мы понятия не имели, почему.
Наш путь к монетизации
Должен сказать, что в мае начался батч S23 YCombinator, а мы не знали точно, какое направление развития будет оптимальным после запуска. Наш групповой партнёр с YC Dalton порекомендовал использовать как ориентир платные подписки и при этом удвоить стоимость любого ежемесячного тарифа, какой бы мы ни придумали. В конечном итоге мы остановились (неохотно) на сумме $40 в месяц. После совещания мы незамедлительно приступили к работе над подготовкой монетизации. Изначально весь стек нашего проекта состоял из NextJS, но мы сначала хотели выполнить миграцию всего на Python/FastAPI. Мы сделали это (частично с помощью ChatGPT), реализовали полную интеграцию Stripe… после чего выдались пять суток самого жестокого за весь месяц недосыпа. (Да, пять дней — долгий срок для нахождения этого бага.)
В течение этих пяти дней мы просыпались в страхе, зная что, увидим в почте 30/40/50 жалоб. Мне бы хотелось чётко донести, сколько клиентов мы потеряли из-за этого. 50 писем в день x 5 дней x $40 =$10000 в месяц потерь на продажах. И это только от тех людей, которые не поленились оставить жалобу. Мы ежедневно отвечали на эти письма, как будто это была наша основная работа. Пользователи жаловались на бесконечный индикатор загрузки, появлявшийся при нажатии на кнопку подписки. Мы начали изучение проблемы с создания нового аккаунта, убедившись, что у нас подписки работают без проблем. Это сбило нас с толку. Ничто из того, что мы делали, не позволяло воссоздать ошибку. Более того, в течение наших часов работы мы практически не получали жалоб.
Галлюцинация на $10 тысяч
По нашим ощущениям, от момента выявления ошибки до её устранения прошёл месяц. После пяти дней бесконечных электронных писем, сотен логов Sentry, долгих переписок с инженерами Stripe и многих часов изучения пяти ключевых файлов мы её обнаружили. Прежде чем продолжить чтение, попробуйте найти её самостоятельно.
Виновником оказалась одна невинно выглядящая строка. Строка, которая отравляла нам жизнь целую неделю. Строка, которая в буквальном смысле стоила нам $10 тысяч. Зловещая строка 56.
Произошло следующее: в рамках миграции бэкенда мы переносили модели баз данных с Prisma/Typescript на Python/SQLAlchemy. Это был очень монотонный процесс. Мы обнаружили, что ChatGPT превосходно справляется с этой трансляцией, поэтому использовали его на протяжении почти всей миграции. Мы копипастили генерируемый им код, видели, что всё работает хорошо, пробовали в продакшене, видели, что он тоже работает, а затем продолжали процесс. Однако на этом этапе мы по-прежнему использовали для всех вставок в базу данных наш Next API. а Python выполнял лишь чтение из базы данных. Впервые мы начали вставлять записи БД на Python, только когда реализовывали подписки. Хотя в процессе этого мы создавали совершенно новые модели SQLAlchemy вручную, получилось так, что мы просто скопировали тот же формат, который ChatGPT написал для старых моделей. Однако мы не заметили того, что продолжали копипастить одну и ту же ошибку генерации ID во всех наших моделях.
Ищем баг
Проблема со строкой 56 заключалась в том, что для генерации UUID наших записей мы просто передавали единственную строку ID. Из-за этого после того, как один новый пользователь выполнял подписку и получал этот ID в каждом отдельном инстансе бэкенда, ни один другой пользователь не мог заново выполнить процесс подписки, потому что это приводило к коллизии уникального ID. Эта проблема оказалась очень хорошо спрятанной из-за структуры нашего бэкенда. У нас было восемь задач ECS в AWS, все они управляли пятью инстансами нашего бэкенда (да, знаю, это перебор, но, честно говоря, у нас были лишние кредиты AWS). Это означало, что у каждого пользователя был пул из сорока потенциально уникальных ID, в которые он мог попасть.
В течение рабочего дня всё было нормально. Мы выполняли коммиты по 10-20 раз в день (разумеется, прямиком в основную ветвь), что вызывало новые развёртывания бэкенда, давая нам сорок новых ID, которые потенциально могли использовать клиенты. Однако ночью, когда мы наконец переставали вносить коммиты (какие мы лентяи, правда?), единственный ID на каждом сервере задействовался, а все новые подписки приводили к коллизиям ID. Пользователи начинали с сорока потенциальных серверов, на которых можно было бы выполнить подписку, но в течение ночи это число быстро снижалось до нуля. Решив наконец эту проблему, мы ощутили, как будто сбросили гору с плеч. Обнаружив источник проблемы, моей коллега Адам быстро запушил исправление, и впервые за эту неделю мы наконец могли спокойно поспать (на самом деле, не совсем, потому что у нас было ещё с десяток проблем, но это уже тема для отдельной статьи).
Заключение
Оглядываясь назад, могу сказать что несмотря на мучительность этих пяти дней, это был один из тех моментов стартапа, которые мы никогда не забудем. Как и все стартапы, мы совершили в процессе развития миллион ошибок, но эта, вероятно, была наихудшей. Возможно, позже я расскажу и об остальных. Сейчас мы просто рады, что можем вспомнить те дни и посмеяться. Да, нам нужно было больше тестировать код. Да, нам не следовало копипастить код. Да, нам не следовало пушить напрямую в основную ветвь. Тем не менее, я не сожалею об этом опыте.
Комментарии (18)
vagon333
10.06.2024 09:17+1Умничать не вижу смысла, лишь сделал для себя do and don't выводы.
Кстати, тоже пользуюсь GPT для трансляции, интерпретации и визуализации логики кода.
Wiggin2014
10.06.2024 09:17+3никак не пойму почему это каждый раз при вызове uuid.uui4() получался один и тот же uuid.
alexac
10.06.2024 09:17+3UUID получается разный, но дефолт для колонки будет сохранен от одного единственного вызова.
pqbd
10.06.2024 09:17+3default вычистится один раз на инстанс и будет передаваться одно и то же значение
santjagocorkez
10.06.2024 09:17Всё дело в том, что в данном случае скобки были не нужны, да и вообще, надо было через server_default и text() делать. Да и вообще, там в первой таблице ещё два кандидата в PK, а в этой subscription_id. id в обеих таблицах не нужен. Просто у них фронтендеры полезли в бэкенд, итог закономерен. Не удивлюсь, если они скоро телепортируются в масло и запилят графкул
Vplusplus
10.06.2024 09:17+1Интересно, каков смысл использовать uuid4 в этом проекте? Типа сразу строим систему планетарного масштаба, где все данные распределены между континентами и разделены во времени? Если бы был обычный инкрементный primarykey, то такой детской ошибки не было.
titan_pc
10.06.2024 09:17Тот случай когда надо было попросить чат переложить все миграции на чистый sql. А оно само напечаталось "Хочу и дальше орм юзать"
CitizenOfDreams
10.06.2024 09:17+2Мы выполняли коммиты по 10-20 раз в день (разумеется, прямиком в основную ветвь)
Здесь есть программисты? Подскажите, а это вообще нормально?
tarielr
10.06.2024 09:17+12Для компании, которая полностью полагается на код написанный ИИ и не тестирует его от слова совсем, абсолютно нормально.
grvelvet
Гениальный план, надёжный как швейцарские часы.