Как избежать «сиротских» файлов (orphaned files) в S3, если не удалось сохранить запись в базе данных?

Разрабатываю сервис (NestJS + TypeORM + PostgreSQL + S3-совместимое хранилище).

Пример: создаю сущность фильма с полями name, description, poster_path. пользователь загружает постер в s3 (aws/filebase и т.п.), получает key и я сохраняю его в БД.

Минимальный пример:

// service
public async createMovie(
  dto: CreateMovieDto,
): Promise<MovieResponseDtoWithRelations> {
  // файл уже загружен в S3, dto.poster_path = ключ объекта в S3
  const movie = await this.moviesRepository.save({
    name: dto.name,
    description: dto.description,
    poster_path: dto.poster_path,
  });

  return serializationEntity(movie, MovieResponseDtoWithRelations);
}

загрузка может происходить как отдельным эндпоинтом с возвратом ключа, так и прямо перед созданием movie.

Проблема: если сохранение в БД не удалось (ошибка транзакции, падение приложения и т.д.), то файл остаётся в S3 без ссылки в БД → получается «сиротский файл» (orphaned file). со временем таких объектов может накопиться очень много.

знаю про outbox pattern для событий, но не уверен, уместно ли строить всю работу с файлами через outbox.

Вопрос: какие практики/паттерны применяются в продакшне, чтобы избежать «сиротских» файлов в S3 при связке с БД? Буду признателен за реальные схемы/подводные камни.


@eri

Я правильно понимаю, что алгоритм работы будет таким:

  1. В movie-controller (метод POST /movie, createMovie) мы принимаем CreateMovieDto, в котором указываем все поля для создания фильма, кроме тех, что содержат путь к файлу. Код:
public async createMovie(
  dto: CreateMovieDto,
): Promise<MovieResponseDtoWithRelations> {
  const posterPathKey = `poster/${uuidv4()}.mp4`;

  const movie = await this.moviesRepository.create({
    posterPath: posterPathKey,
    name: dto.name,
    description: dto.description,
  });

  if (!movie) {
    throw new BadRequestException("Failed to create movie.");
  }

  const posterPathUploadUrl = await this.generatePresignedUrl(posterPathKey, "image/jpg");

  return {
    posterPathUploadUrl,
    movie: serializationEntity(movie, MovieResponseDtoWithRelations),
  };
}
  1. На клиенте загружаем файл в S3 по выданному presignedUrl:
const formData = new FormData(e.target);
const file = formData.get("file");
const response = await axios.put(presignedUrl, file, {
  headers: { "Content-Type": file.type },
});

Всё ли я правильно понял или где-то есть нюансы, которые я упускаю?


Ответы (1 шт):

Автор решения: eri

Я бы использовал presigned_URL для загрузки. Добавить в ключ uuid, подписать ссылку и сохранить её в базу. При успешном сохранении передаем ссылку на аплоад.

В этом случае будут битые ссылки в базе, но их проще валидировать потом. Можно просить загрузить ещё раз в тот же ключ.

→ Ссылка