Файловое хранилище: очистка сирот и сроки хранения
Даже корректная процедура удаления не предотвращает все сироты. Браузер может не подтвердить upload, подпись может быть сформирована после резервирования и потеряться, worker может записать производную перед аварией SQL. Добавим периодическое обслуживание «Архива». Оно переводит просроченные карточки в удаление, завершает отложенную очистку и собирает старые объекты, на которые не ссылаются живые записи.
Retention — правило срока хранения, а не просто таймер в одном процессе. Для лаборатории карточка создаётся на девяносто дней, незавершённая попытка получает дополнительное окно после upload-истечения, а бесхозные объекты удаляются только после суток. Эти разные интервалы относятся к разным фактам. Выбирать одну общую цифру для всех объектов было бы опасно: активная загрузка и давно потерянная производная имеют разный смысл.
Просроченные карточки
Cleanup сначала выбирает pending, чей билет истёк более часа назад. Такой резерв не должен оставаться навсегда, но небольшая задержка помогает не вмешиваться в ещё завершающуюся сетевую операцию. Для queued, processing, ready и rejected проверяется общий expires_at. Затем используется та же markDeleting(), что и для пользовательского DELETE, поэтому ownership и переходы не получают вторую несовместимую реализацию.
Полный файл cleanup.mjs состояния 11:
import { db } from './db.mjs';
import { versions,removeVersions,requireVersioning } from './store.mjs';
import { markDeleting,deleteDue } from './deletion.mjs';
await requireVersioning();
try {
const stale=await db.query(`SELECT id,owner_id FROM archive_files WHERE
(status='pending' AND upload_expires<now()-interval '1 hour') OR
(status IN ('queued','processing','ready','rejected') AND expires_at<now())`);
for(const row of stale.rows) await markDeleting(row.owner_id,row.id);
await deleteDue();
const live=await db.query(`SELECT source_key,source_version,derived_key,derived_version FROM archive_files
WHERE status<>'deleted'`);
const protectedKeys=new Set(live.rows.flatMap(r=>[r.source_key,r.derived_key]).filter(Boolean));
for(const prefix of ['incoming/','derived/']) {
const candidates=(await versions(prefix)).filter(x=>!protectedKeys.has(x.Key) &&
new Date(x.LastModified).getTime()<Date.now()-24*3600*1000);
await removeVersions(candidates);
}
} finally { await db.end(); }
Очистка запускается отдельно от API. В пакете подготовлен одноразовый проход, который можно вызывать внешним расписанием после самостоятельного запуска среды. Само расписание здесь не создано, а cleanup не выполнялся. Для производства задают владельца процесса, частоту и наблюдение ошибок. Если обслуживанием никто не управляет, наличие файла в исходниках не обеспечивает реального освобождения места.
deleteDue() выполняется до сборки сирот. Она заканчивает операции, уже явно запрошенные карточкой. После этого cleanup строит набор защищённых ключей из всех не удалённых записей. Консервативно защищается весь ключ, а не только одна принятая версия. Это предотвращает удаление версии активной попытки, пока состояние ещё может измениться, но оставляет лишние версии живого ключа до удаления карточки.
Что считать сиротой
Сирота — объект под нашим управляемым префиксом, который не защищён живой карточкой и достаточно стар. Отсутствие ссылки прямо сейчас недостаточно: новый worker мог только что завершить PUT и ещё не сделать COMMIT. Поэтому добавляем сутки между временем последнего изменения объекта и возможным удалением. Такой grace interval намного длиннее нормальной двухминутной обработки и периода upload-билета лаборатории.
Для incoming/ защищённым является source_key; для derived/ — текущий derived_key. Производная новой попытки имеет случайный ключ. Пока UPDATE не сделал её официальной, она не защищена набором, но её возраст ещё не позволяет удалить объект. Если UPDATE провалился окончательно, спустя сутки объект станет кандидатом. Это механизм компенсации разрыва между S3 и PostgreSQL без общей распределённой транзакции.
Граница суток предполагает, что никакой законный процесс не держит промежуток между PUT и фиксацией настолько долго. Поэтому внешние лимиты времени worker являются частью корректности очистки. Если сервис допускает обработки длительностью несколько дней, нужно хранить отдельное намерение производной до PUT и учитывать его в защитном наборе. Просто увеличить срок сирот навсегда означало бы отказаться от управляемой очистки.
Не перечисляем весь бакет без ограничений. Управляемые префиксы incoming/ и derived/ отделяют байты этой модели от других возможных данных. Даже внутри них role cleanup должна иметь только нужные права. Ключ, присланный пользователем, не может расширить область удаления. Для первых производственных проходов полезен режим планирования кандидатов без удаления, однако его наличие следует реализовать отдельно, а не утверждать, что показанный скрипт уже делает dry run.
Версии и lifecycle хранилища
Каждый повтор upload может оставить дополнительную версию по защищённому исходному ключу. В лаборатории они удаляются вместе с карточкой. Это проще и безопаснее, но увеличивает физический расход по сравнению с логическим резервом одного файла. При больших объёмах можно отдельно собирать старые непринятые версии, проверяя version references и отсутствие действующих билетов. Такая оптимизация требует собственного протокола, а не безусловного удаления noncurrent versions.
Lifecycle rules провайдера удобны для долгосрочной автоматизации, но могут нарушить ссылки базы. Если правило удаляет все noncurrent versions через день, принятая версия A может исчезнуть после повторного upload версии B, хотя карточка ещё девяносто дней обещает доступ. Следовательно, политику жизненного цикла согласуют с точным способом адресации версий и сроком карточки. База не узнаёт автоматически об исчезнувших байтах.
Журнал и tombstones имеют отдельный срок. Они нужны для объяснения удаления, но не должны бессрочно копировать пользовательские имена или содержимое. В нашей записи аудита таких данных нет. Для реального портала срок определяется требованиями компании и доступом операторов; учебные девяносто дней описывают фотографии, а не универсальное правило для всех журналов. Одна операция очистки не заменяет политику обращения с данными.
Наблюдение результата обслуживания
Успешный проход должен быть виден по числу выбранных карточек, удалённых версий, частичных отказов и оставшейся задолженности. Объём корзины нельзя выводить только из sum(expected_bytes): versioning и производные дают дополнительный расход. API-квота является логическим ограничением пользователя, а биллинг S3 описывает физические версии и операции. Сопоставление этих величин помогает обнаружить накопление незавершённых объектов.
Проблема очистки должна приводить к понятному повтору, а не к слепому продолжению. DeleteObjects может сообщить ошибки для отдельных версий. Адаптер выбрасывает исключение и не отмечает завершение, поэтому оператор видит необходимость нового прохода. Для большой очереди нужна пагинация карточек и ограниченный размер порции; в учебном deleteDue() выбраны сто записей, а общий проход retention остаётся намеренно простым.
Ожидаемый эффект одиннадцатого состояния — истёкшие карточки перестают получать новые ссылки, затем освобождаются их версии и резерв, а старые несвязанные результаты удаляются отдельно. Ничего из этого не объявлено наблюдённым на живом бакете. Вы можете объяснить каждый срок и каждое условие кандидата; именно такое объяснение требуется перед передачей удаления реальному расписанию.
Предыдущий урок: Удаление файла и гонки операций · Следующий урок: Подготовка архива к выпуску
Оглавление серии · Исходники всех состояний.
Документация: S3: lifecycle, S3: версии.