Удаление файла и гонки операций
У «Архива» появился worker, и удаление стало сложнее простого DELETE в базе. В момент запроса файл может обрабатываться, upload-билет может ещё действовать, а ссылка скачивания может быть уже выдана. Разделим отказ новых операций и физическое удаление версий. Сначала карточка становится deleting, затем отдельная процедура удаляет байты и отмечает deleted.
Результат главы — согласованная процедура для своей карточки, которая переживает повтор запроса и конфликт со старым worker. Мы не обещаем немедленного исчезновения всех пользовательских копий: скачанный документ и уже начатое чтение находятся за пределами простой смены статуса. Интерфейс должен различать «удаление запрошено» и «удаление завершено», потому что это реальные разные этапы системы.
Сначала закрываем новые разрешения
markDeleting() получает владельца и UUID, выбирает строку с блокировкой и проверкой ownership. Для deleting и deleted возвращает текущий статус без нового побочного эффекта. Для остальных состояний устанавливает deleting, сбрасывает token обработки и задаёт delete_after. После COMMIT маршрут download не пройдёт проверку ready, а confirm не сможет вернуться к принятию файла.
Полный файл deletion.mjs состояния 10:
import { transaction,db } from './db.mjs';
import { owned } from './auth.mjs';
import { versions,removeVersions } from './store.mjs';
export async function markDeleting(owner,id) {
return transaction(async client=>{
const row=await owned(client,id,owner,true);
if(['deleting','deleted'].includes(row.status)) return {id,status:row.status};
await client.query(`UPDATE archive_files SET status='deleting',lease_token=NULL,
delete_after=GREATEST(now()+interval '2 minutes',upload_expires+interval '5 minutes')
WHERE id=$1`,[id]);
await client.query("INSERT INTO archive_audit(file_id,event) VALUES($1,'deletion_requested')",[id]);
return {id,status:'deleting'};
});
}
export async function deleteDue() {
const q=await db.query("SELECT * FROM archive_files WHERE status='deleting' AND delete_after<=now() LIMIT 100");
for(const row of q.rows) {
const incoming=(await versions(row.source_key)).filter(x=>x.Key===row.source_key);
const derived=await versions(`derived/${row.id}/`);
await removeVersions([...incoming,...derived]);
await db.query("UPDATE archive_files SET status='deleted' WHERE id=$1 AND status='deleting'",[row.id]);
}
}
Сброс lease_token делает результат старой обработки неприменимым. Worker может ещё завершить S3 PUT, но его условный UPDATE не найдёт прежний token и processing. Уникальный ключ производной предотвращает перезапись принятого объекта. Такой лишний результат не становится доступным пользователю и будет удалён сборкой сирот. Поэтому право фиксации результата отзывается прежде, чем начинается физическая очистка.
Строка сохраняется как tombstone. Она показывает, что карточка существовала и прошла удаление, а повтор DELETE не создаёт неожиданную ошибку после успешного действия. Полный срок хранения tombstone и аудита задаётся отдельно. Не нужно автоматически удалять журнал в той же транзакции: история запроса удаления помогает разбирать незавершённые операции и восстановление после отказа очистки.
Два времени ожидания
Первый интервал учитывает download-ссылки. Мы выдаём их на шестьдесят секунд, а физическое удаление откладываем хотя бы на две минуты. Этот запас также ограничивает гонку между чтением строки и формированием ссылки: запрос мог начать разрешённую операцию до смены состояния. Интервал не является доказательством прекращения всех активных соединений, но задаёт разумную границу для новых коротких разрешений лаборатории.
Второй интервал связан с upload. Presigned POST допускает повтор до своего истечения. Если сразу физически удалить объект pending, пользователь сможет повторно загрузить байты по ещё действующему билету и получить сироту после удаления. Поэтому delete_after не раньше upload_expires плюс пять минут. До этого времени объектные байты могут существовать, но приложение уже не разрешает их принятие и выдачу.
Используем максимум двух сроков, а не их сумму. Если upload-билет давно истёк, достаточно интервала download. Если удаление запрошено сразу после резервирования, доминирует ожидание upload. Такая запись делает правило видимым в SQL и помогает объяснить пользовательский статус. В реальном продукте размеры запасов выбирают по времени передачи и особенностям провайдера; они не являются универсальными константами безопасности.
Удаление всех версий
В versioned bucket обычный DELETE по ключу может создать delete marker и оставить прежние версии. Для физической очистки перечисляем версии и delete markers, затем передаём пары Key и VersionId в DeleteObjects. Адаптер разбивает список на пакеты не более тысячи и проверяет Errors даже при успешном HTTP-ответе. Частичный отказ нельзя считать завершённым удалением.
Исходный ключ выбирается точно: после перечисления по префиксу дополнительно проверяем равенство Key===source_key. Иначе ключ, начинающийся так же, мог бы попасть под удаление. Для производных используется серверный префикс derived/<uuid>/, который создаёт только наш pipeline. Клиент не выбирает его значение. Правило адресации связано с моделью карточки и не допускает произвольного пути из DELETE-запроса.
После успешного удаления байтов карточка становится deleted. Если S3 вернул частичную ошибку, UPDATE не выполняется и следующий проход сможет повторить операцию. Повторное удаление уже удалённой версии обычно безопасно по контракту выбранного сервиса, но совместимость нужно проверить. Отдельная ошибка SQL после S3-удаления также оставит deleting; следующий проход снова подтвердит очистку и закончит переход.
Нет общей транзакции PostgreSQL и S3. Поэтому порядок выбран так, чтобы отказ оставлял работу повторяемой. Сначала запрещаем новые выдачи в базе, затем удаляем байты, затем фиксируем завершение. Обратный порядок мог бы показать deleted, хотя физическое удаление не удалось. Без промежуточного состояния нельзя объяснить пользователю и оператору, какой шаг остаётся незавершённым.
Гонка с поздним worker
Представим, что worker начал до DELETE, пережил истечение lease и записал производную после первого прохода удаления. База уже не примет этот результат, но физический объект может появиться позднее. Процедура очистки сирот в следующем уроке нужна именно для таких разрывов. Одного прохода по известным на данный момент версиям недостаточно, чтобы гарантировать отсутствие всех будущих записей старого процесса.
В эксплуатации worker ограничивают по времени и прекращают процесс, потерявший lease, когда это возможно. Fencing на UPDATE предотвращает официальное принятие, а процессные лимиты ограничивают продолжительность побочного эффекта. Это два разных механизма. Разрешение роли worker на S3 тоже не отзывается автоматически при изменении одной карточки: объектное хранилище не знает token SQL-очереди.
Квота освобождается лишь после deleted. Пользователь может временно видеть «удаляется», хотя новые upload по лимиту ещё отказаны. Это честное поведение консервативной модели. Если продукт хочет немедленно возвращать логическую квоту, понадобится отдельный учёт физического бюджета и ограничение задолженности очистки. Иначе массовое удаление и повторная загрузка обойдут расход хранения.
Ожидаемый DELETE собственной карточки возвращает 202 с deleting, повтор — тот же статус, чужая карточка — 404. Новое скачивание после фиксации удаления не выдаёт URL. Окончательный переход зависит от отдельного прохода deleteDue(), который будет вызываться обслуживанием архива. Так удаление стало частью конечного автомата и протокола повторов, а не однократной надеждой на успешный сетевой запрос.
Предыдущий урок: Фоновая обработка файлов · Следующий урок: Очистка сирот и сроки хранения
Оглавление серии · Исходники всех состояний.
Документация: S3: DeleteObjects, S3: ListObjectVersions.