Переименование файла (ФС + Postgres) в транзакции

94. Переименование файла (ФС + Postgres) в транзакции

Условие задачи:

Есть система, которая дает юзерам возможность работать с файлами в браузере. Стек стандартный. Java, Spring, React, Postgres. Файлы хранятся на файловой системе на беке. Метаданные файлов в БД. Команда реализовала фичу - переименование файла. К вам пришел разработчик и показывает код на ревью. В команде принято следующее: На код ревью приходит код с зеленым билдом, подвохов с компиляцией нет. Код базово покрыт автотестами с поднятием Спрингового контекста. Подвохов со Спринг прокси тоже нет. Проведите код ревью

Код:

@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); //
выполнение запроса к БД
Спойлеры к решению
Подсказки
💡 Здесь смешаны две «транзакции»: БД и файловая система. ФС не откатится при rollback БД.
💡 SQL-конкатенация = SQL injection + проблемы с кавычками + экранирование.
💡 file — потенциально зарезервированное слово/плохое имя таблицы.
💡 Поиск по name может вернуть несколько строк → не определено поведение.
💡 Гонки: два запроса + операция ФС между ними → возможны race conditions.
💡 Нет проверки на существование newName (уникальность), нет обработки коллизий на ФС.
💡 Нет валидации входа (пустые имена, path traversal ../).
💡 Нет «атомарности»: что делать, если файл переименовался, а update не прошёл (или наоборот)?
Решение

Что плохо в текущем решении #

  1. SQL Injection + кавычки
"where name='" + oldName + "'"
  • Любой oldName с ' ломает запрос.

  • Можно инжектить SQL.

✅ Исправление: только параметризованные запросы (PreparedStatement/JdbcTemplate/Repository).

  1. Несогласованность БД и файловой системы
  • @Transactional откатывает только БД.

  • Если processFile() переименовал файл, а update упал → БД откатится, а файл останется переименованным.

  • Если наоборот: БД обновили, а файл не смогли переименовать → метаданные врут.

✅ Нужна стратегия согласованности:

  • либо сначала БД + запись “в процессе”, потом ФС, потом “готово” (saga/state machine),

  • либо outbox/интеграционный подход,

  • либо строгое правило: сначала ФС, потом БД, и при ошибке ФС делать компенсирующее действие (rename back).

  1. Гонки и конкуренция
  • Между select и update может вмешаться другой поток:

    • файл уже переименовали,

    • запись изменили,

    • появилось другое имя.

✅ Решение:

  • использовать SELECT ... FOR UPDATE по id,

  • или сразу UPDATE ... WHERE name = ? и проверять количество обновлённых строк,

  • или уникальный индекс на name + обработка duplicate key.

  1. Поиск по имени
  • Имя не обязано быть уникальным (или не сказано).

  • select id where name=... может вернуть несколько строк.

✅ В модели должен быть стабильный идентификатор: переименовываем по fileId, а не по имени.

  1. Валидация и безопасность имени
  • newName может содержать ../, \0, слэши, спецсимволы → path traversal, выход из директории.

  • oldName/newName могут быть пустыми или слишком длинными.

✅ Валидация:

  • разрешённый набор символов,

  • запрет .., /, \,

  • длина,

  • нормализация Unicode (по требованиям).

  1. Нет обработки коллизий
  • newName уже существует:

    • на ФС (файл с таким именем есть),

    • в БД (уникальность должна быть обеспечена).

✅ Решение:

  • уникальный индекс в БД (например, (folder_id, name)),

  • проверка/обработка FileAlreadyExistsException.


Как бы я переписал (концептуально, без привязки к ORM) #

Идея: работать по fileId, держать блокировку и гарантировать согласованность.

Псевдо-код:

@Transactional
public void rename(long fileId, String newName) {
    // 1) заблокировали запись
    FileMeta meta = repository.findByIdForUpdate(fileId)
            .orElseThrow(...);

    String oldName = meta.getName();

    // 2) валидация
    validateName(newName);

    // 3) обновили БД (можно поставить статус RENAMING)
    meta.setName(newName);
    repository.save(meta);

    // 4) после коммита делаем ФС-операцию
    // чтобы не держать транзакцию БД на время IO
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronizationAdapter() {
            @Override
            public void afterCommit() {
                try {
                    fileStorage.rename(oldName, newName); // atomic move в пределах FS
                } catch (Exception ex) {
                    // компенсация: отправить в очередь на reconcile/rollback
                    // например, outbox event "RENAME_FAILED"
                }
            }
        }
    );
}

Пояснение:

  • Внутри транзакции БД — только работа с метаданными (быстро).

  • IO (файловая система) — после коммита, чтобы не держать транзакцию открытой.

  • Если rename на ФС упал — нужен механизм компенсации (reconcile job / outbox / статус “ошибка”).


Минимальные обязательные правки прямо сейчас (если “быстро пофиксить”) #

  • Параметризовать SQL.

  • Переименовывать по fileId, а не по имени.

  • Сделать уникальность имени (обычно по папке) на уровне БД.

  • Добавить валидацию newName.

  • Добавить обработку ошибок и компенсацию (rename back / статус).


Сильный “production” вариант #

  • В БД: поля status (READY/RENAMING/ERROR), updated_at, уникальный индекс (folder_id, name).

  • События: outbox “FILE_RENAMED” / “FILE_RENAME_FAILED”.

  • Фоновая джоба reconcile: сверяет БД и ФС, чинит расхождения.