Фоновая задача, которая чуть не удалила ваш аватар

Product

В v0.4.0 у рабочих пространств появились аватары. Функция маленькая: загружаете картинку, и она видна в переключателе пространств. Загрузка заработала с первого раза. Картинка отображалась везде, где должна была. Все тесты прошли.

А через три дня она бы исчезла.

Два способа потерять файл

Файлы в Notez попадают в объектное хранилище раньше, чем на них что-то сошлётся в базе данных. Так задумано: браузер отправляет байты прямо в S3 через presigned POST, и большая загрузка никогда не занимает API. Сначала записывается строка с описанием файла, потом приходят байты.

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

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

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

Для аватаров рабочих пространств не делалось ни того ни другого. URL записывался в рабочее пространство, картинка отображалась, а файл лежал с пометкой «загружен», без привязки, и через три дня попал бы под удаление.

Почему ошибку ничто не поймало

Тесты прошли, потому что проверяли то, что я сделал. Загрузка возвращает URL, URL сохраняется, компонент его отображает — всё верно, и всё остаётся верным, даже когда ошибка есть. Ни одна проверка не касалась логического флага в другой таблице, потому что мне не пришло в голову, что флаг в другой таблице — часть функции.

К тому же сразу сбой не виден. Нет ни ошибки, ни предупреждения в консоли, ни упавшего запроса. Между ошибкой и симптомом — три дня и один запуск cron, а симптом — битая картинка на чужом экране.

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

Удаление — тоже изменение

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

Теперь при удалении рабочего пространства его иконка тоже освобождается.

Общее правило: если фоновая задача решает, что оставить, по тому, ссылается ли что-то на файл, то каждый путь в коде, который перестаёт на файл ссылаться, обязан об этом сказать. Замена. Очистка. Удаление родителя. Пропустите один — получите утечку; сделайте наоборот — потеряете данные. Снаружи они совсем не похожи друг на друга, а причина у них одна — недостающая строка.

Что я изменил

  • Установка аватара рабочего пространства помечает файл привязанным, и задача очистки его не трогает.

  • Замена аватара ставит старый файл в очередь на удаление, с тем же 30-дневным окном восстановления, что и у всего, что вы удаляете.

  • Удаление рабочего пространства освобождает его аватар, а не бросает файл на произвол судьбы.

Теперь жизненный цикл покрывают семь тестов, включая случаи, когда ничего происходить не должно: переименование рабочего пространства не должно ставить аватар в очередь на удаление, а повторное сохранение того же URL — не замена. Прежде чем доверять этим тестам, я проверил, что без исправления они падают.

Читайте также