Провести код-ревью переименования файла

34. Провести код-ревью переименования файла

Условие задачи:
Система хранит файлы на файловой системе, а их метаданные — в PostgreSQL.

Необходимо провести код-ревью метода, который переименовывает файл и обновляет его имя в базе данных.

Код:

@Transactional
public void process(String oldName, String newName) {
    Long id = exec(
            "select id from file where name='"
                    + oldName + "'"
    );

    processFile(oldName, newName);

    exec(
            "update file set name=\""
                    + newName
                    + "\" where id = "
                    + id
    );
}

Спойлеры к решению

Подсказки
💡 @Transactional управляет транзакцией базы данных, но не файловой системой.
💡 SQL нельзя собирать конкатенацией строк.
💡 Переименовывать файл надёжнее по идентификатору, а не по имени.
💡 Необходимо проверить новое имя и возможные коллизии.
💡 Между чтением записи и её обновлением возможны конкурентные изменения.
💡 Для сбоя между операциями нужен откат, повторная обработка или механизм сверки.

Решение

Основные проблемы:

  1. SQL-инъекция из-за конкатенации параметров:
"where name='" + oldName + "'"

Нужно использовать параметризованные запросы или Spring Data Repository.

  1. Аннотация @Transactional распространяется только на PostgreSQL. Если файл уже переименован, а обновление базы завершилось ошибкой, файловая система автоматически не откатится.

  2. Поиск по имени ненадёжен. Имя может быть неуникальным или измениться конкурентным запросом. Метод лучше принимать fileId.

  3. Необходимо проверять newName:

  • пустое значение;

  • запрещённые символы;

  • ../, / и \;

  • превышение допустимой длины;

  • существование файла с таким именем.

  1. Уникальность имени должна обеспечиваться ограничением в базе данных, например по паре (directory_id, name).

Минимальный вариант с блокировкой записи и компенсацией:

@Transactional
public void rename(long fileId, String newName) {
    validateFileName(newName);

    FileMetadata file = repository.findByIdForUpdate(fileId)
            .orElseThrow(() ->
                    new IllegalArgumentException("File not found")
            );

    String oldName = file.getName();

    if (oldName.equals(newName)) {
        return;
    }

    storage.rename(oldName, newName);

    try {
        file.setName(newName);
        repository.saveAndFlush(file);
    } catch (RuntimeException exception) {
        try {
            storage.rename(newName, oldName);
        } catch (RuntimeException rollbackException) {
            exception.addSuppressed(rollbackException);
        }

        throw exception;
    }
}

Репозиторий с блокировкой:

public interface FileMetadataRepository
        extends JpaRepository<FileMetadata, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("""
            select file
            from FileMetadata file
            where file.id = :id
            """)
    Optional<FileMetadata> findByIdForUpdate(long id);
}

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

Для production-решения лучше использовать последовательность состояний:

READY → RENAMING → READY
                  ↘ ERROR

Запрос создаёт операцию переименования в базе. Отдельный обработчик выполняет операцию на файловой системе, поддерживает повторные попытки и после успеха обновляет метаданные.

Дополнительно нужна фоновая сверка базы данных и файловой системы, которая обнаруживает и исправляет незавершённые операции.