Перейти к содержанию

Подтверждение загруженной версии

После прямой отправки в S3 браузер знает ответ хранилища, но приложение ещё не приняло файл. Добавим POST /v1/files/<id>/confirm. Этот маршрут получает только UUID собственной карточки. Сервер сам читает сохранённый ключ, проверяет объект и фиксирует его версию. Клиент не передаёт доверенный размер, состояние, бакет или ключ, которые позволили бы подтвердить чужую загрузку.

В результате pending перейдёт в queued. Слово «подтверждение» здесь означает строго определённые факты: объект существует, длина и заявленный Content-Type совпали, доступна конкретная версия. Содержимое ещё не прошло decoder. Поэтому список может показать «ожидает обработки», но download остаётся закрытым. Такой контракт не выдаёт проверку метаданных за безопасность самих байтов.

Блокировка перехода

Алиса может дважды нажать кнопку подтверждения или повторить запрос после потери ответа. Если два обработчика без блокировки одновременно прочитают pending, оба начнут менять состояние и создавать задания. Поэтому выбираем карточку через owned(..., true) внутри транзакции. Проверка владельца и FOR UPDATE находятся в одном запросе, но выполняют разные обязанности: право доступа и сериализацию изменения.

Полный файл confirm.mjs состояния 06:

import { transaction } from './db.mjs';
import { owned } from './auth.mjs';
import { Failure } from './http.mjs';
import { head } from './store.mjs';
export async function confirm(owner,id) {
  return transaction(async client=>{
    const row=await owned(client,id,owner,true);
    if(['queued','processing','ready'].includes(row.status)) return {id,status:row.status};
    if(row.status!=='pending') throw new Failure(409,'invalid_state');
    if(new Date(row.upload_expires).getTime()<Date.now()) throw new Failure(410,'ticket_expired');
    let object;
    try { object=await head(row.source_key); }
    catch(error) { if(error.$metadata?.httpStatusCode===404) throw new Failure(409,'upload_missing'); throw error; }
    if(object.ContentLength!==row.expected_bytes || object.ContentType!==row.declared_type)
      throw new Failure(422,'metadata_mismatch');
    if(!object.VersionId || object.VersionId==='null') throw new Failure(503,'versioning_required');
    await client.query(`UPDATE archive_files SET source_version=$2,status='queued' WHERE id=$1`,
      [id,object.VersionId]);
    await client.query("INSERT INTO archive_audit(file_id,event) VALUES($1,'confirmed')",[id]);
    return {id,status:'queued'};
  });
}

Для уже принятого состояния queued, processing или ready возвращаем текущий статус. Повтор не меняет source_version и не создаёт новую работу. Это идемпотентность конкретного перехода после успешной фиксации. Она не распространяется автоматически на предыдущий маршрут создания upload-билета, который создаёт новую карточку при каждом вызове. Разные операции требуют разных правил повторения.

Состояния rejected, deleting и deleted не разрешают вернуться к подтверждению. Иначе старый действующий upload-билет мог бы воскресить удалённую карточку. Такие отказы возвращают 409. Для истёкшего pending используется 410: разрешённое окно попытки закончено. Клиент должен начать новую попытку, а старый объект, если он всё же существует, перейдёт в область очистки.

В лаборатории сетевой HeadObject выполняется, пока удерживается блокировка строки. Это упрощает объяснение атомарного перехода, но удлиняет транзакцию при медленном хранилище. В реальном сервисе можно использовать предварительное чтение и условное изменение по revision, повторно проверяя состояние. Нельзя просто вынести все проверки из транзакции и оставить безусловный UPDATE, потому что тогда вернётся гонка с удалением.

Какие факты сообщает HEAD

HeadObject возвращает метаданные объекта без загрузки его содержимого. Длина должна совпасть с expected_bytes, тип — с declared_type. Политика POST уже ограничивала эти значения, но повторная проверка защищает согласованность карточки и поддерживает ясный контракт при различиях провайдеров. Ни длина, ни Content-Type не говорят, что изображение полностью декодируется.

Ответ должен содержать ненулевой VersionId. Если версия отсутствует или равна строке null, API возвращает технический отказ. Нельзя подменять её ETag: ETag и идентификатор версии решают разные задачи. Для разных способов загрузки ETag не обязан быть обычным MD5 всего объекта, а идентификатор версии нужен для адресации ровно тех байтов, которые будут обработаны позже.

Представим гонку. HEAD увидел версию A; сразу после него клиент повторил разрешённую загрузку и создал версию B. Сервер сохраняет A. Worker позднее выполняет GET с VersionId=A, поэтому обрабатывает принятую версию независимо от текущей. Без versioning GET по одному ключу получил бы B, и проверенные метаданные могли бы относиться к другому содержимому. Это причина требования предыдущих глав, а не дополнительная декоративная колонка.

Отсутствующий объект возвращает upload_missing. Это ожидаемое состояние при преждевременном подтверждении или оборванной отправке. Ошибка credentials либо недоступность S3 не маскируется под отсутствие: пользователь не должен повторно загружать фотографию из-за технической ошибки роли. В исходнике отдельно распознаётся HTTP 404, а остальные исключения доходят до общей технической ошибки API.

Очередь как часть записи

В нашей лаборатории карточка со статусом queued сама является записью SQL-очереди. Нет отдельного внешнего брокера, который нужно синхронизировать с PostgreSQL. Установка версии, переход в очередь и событие confirmed фиксируются в одной транзакции. После COMMIT worker сможет увидеть задачу; при ROLLBACK не появится ни принятой версии, ни задания, которое ссылается на непринятый объект.

Для сложной системы обычно применяют отдельную таблицу задач или outbox, но смысл границы остаётся тем же. Сообщение нельзя публиковать до фиксации карточки, если consumer способен начать работу сразу. Иначе он прочитает отсутствующую либо старую запись. SQL-очередь позволяет сначала изучить эту проблему без дополнительного протокола доставки и уже потом вынести задания в другой сервис.

Ожидаемый ответ первого успешного подтверждения — 202 и status: "queued". Этот статус честно сообщает, что результат ещё не готов. До восьмого урока обработчика в проекте нет, поэтому карточка останется в очереди. В тексте не приведено измеренное время её перехода: исполнение приложения не выполнялось, а реальная задержка зависит от worker и содержимого изображения.

Если ответ потерялся после COMMIT, повтор возвращает queued. Если соединение оборвалось до COMMIT, повтор снова проверит pending. Клиенту не нужно угадывать момент сетевой ошибки. Важен сохранённый UUID и серверное состояние. При этом исходный upload-билет всё ещё может быть действителен, поэтому последующие операции всегда используют закреплённую версию, а очистка удалит лишние версии по отдельному правилу.

Теперь «Архив» умеет отличать разрешённую попытку от зарегистрированной загрузки. Добавленный факт достаточно силён для постановки обработки, но недостаточен для скачивания. В следующем уроке мы создадим маршрут временной выдачи, который потребует ready; затем decoder научит проект получать это состояние через реальную обработку байтов.

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

Предыдущий урок: Разрешение на прямую загрузку · Следующий урок: Временная ссылка на скачивание

Оглавление серии · Исходники всех состояний.

Документация: S3: HeadObject, S3: версии.