0% прочитано

Тимчасовий файл ще не є постійним: staging перед активацією

Після прямого завантаження файл ще не обов’язково готовий стати частиною системи. Я спочатку кладу його у tmp, перевіряю, зберігаю стан і лише потім активую.

11 жовтня 2026 р. 11 хв читанняLaradock

Після прямого завантаження у S3-сумісне сховище браузер уже може передати великий файл безпосередньо у сховище.

Але успішний PUT ще не означає, що файл повинен одразу стати постійною частиною системи.

Між "байти вже лежать у S3" і "цей файл активний у застосунку" мені потрібна окрема межа.

Для цього я використовую staging: спочатку файл потрапляє у тимчасовий шлях на кшталт tmp/..., потім проходить перевірку, після цього стан зберігається, і лише тоді файл активується.

Тимчасовий об’єкт означає: файл уже фізично існує, але застосунок ще не вважає його завершеним і активним.

Мій базовий потік: upload -> temporary storage -> validation -> persist -> activate

Файл стає постійним не в момент upload
upload  |  vtemporary storagetmp/...  |  vvalidation  |  vpersist pending state  |  vpromote object  |  v

Цей порядок дає застосунку час перевірити те, чого не може гарантувати сам факт успішного HTTP-завантаження.

Наприклад: чи об’єкт справді існує, чи відповідає очікуваному розміру, чи має допустимий тип і чи взагалі цей upload ще належить актуальній бізнес-операції.

Тимчасовий шлях відділяє отримані байти від активних файлів

Сервер сам формує тимчасовий шлях
$temporaryPath = sprintf(    'tmp/uploads/%s/%s',    (string) $user->id,    (string) Str::uuid(),);

Я не дозволяю браузеру самому визначати кінцевий шлях об’єкта.

Сервер створює тимчасовий ключ і видає короткоживучий дозвіл саме для нього.

Префікс tmp/ одразу показує, що об’єкт ще не є канонічним файлом системи.

Upload URL дає право записати лише тимчасовий об’єкт

Короткоживучий дозвіл веде у tmp
[    'url' => $url,    'headers' => $headers,] = Storage::disk('s3')    ->temporaryUploadUrl(        $temporaryPath,        $uploadExpiresAt,    );

Це добре поєднується з окремого строку дії для upload і read URL: дозвіл на запис короткий і веде не в постійний namespace, а в staging.

Навіть якщо upload завершився, застосунок ще має вирішити, чи цей об’єкт можна прийняти.

Успішний PUT не є активацією файла

Коли S3 повернув успіх, це підтверджує тільки завершення конкретного storage request.

Це не підтверджує, що користувач не скасував операцію, що форма ще актуальна, що бізнес-сутність існує або що metadata файла відповідає очікуванням.

Тому я не створюю логіку за принципом "upload succeeded, therefore file is active".

Після upload клієнт окремо просить завершити операцію

Storage upload і business completion є різними кроками
await fetch(upload.url, {    method: 'PUT',    headers: upload.headers,    body: file,}); await fetch('/files/complete-upload', {    method: 'POST',    headers: {        'Content-Type': 'application/json',    },    body: JSON.stringify({        uploadId: upload.id,    }),});

Перший запит передає байти у сховище.

Другий повертає керування застосунку, де можна повторно авторизувати дію, знайти очікуваний temporary object і виконати перевірки.

Клієнт передає внутрішній upload identifier, а не довільний storage path.

Я перевіряю реальний об’єкт у сховищі, а не лише дані з браузера

Перевірка об’єкта після прямого upload
$disk = Storage::disk('s3'); if (! $disk->exists($temporaryPath)) {    throw ValidationException::withMessages([        'file' => 'Uploaded object was not found.',    ]);} $size = $disk->size($temporaryPath);$mimeType = $disk->mimeType($temporaryPath); if ($size > $maxBytes) {    throw ValidationException::withMessages([        'file' => 'Uploaded file is too large.',    ]);}

Перед видачею upload URL я можу перевірити заявлені клієнтом ім’я, тип і розмір.

Після upload я вже маю можливість перевіряти фактичний об’єкт у сховищі.

Саме цей другий етап визначає, чи temporary object може рухатися далі.

Перед активацією я зберігаю проміжний стан

Запис існує, але файл ще не active
$file = File::create([    'status' => FileStatus::PENDING,    'temporary_path' => $temporaryPath,    'path' => null,    'size' => $size,    'mime_type' => $mimeType,]);

Проміжний запис дає застосунку власний стан операції.

Він дозволяє відрізняти "об’єкт завантажено" від "об’єкт активовано" і зберігати потрібну metadata до фінального переходу.

Назви статусів можуть відрізнятися між проєктами. Важливий сам принцип: до завершення всіх кроків файл не повинен виглядати як повністю готовий.

Після перевірки об’єкт переходить у постійний namespace

Логічне просування з tmp у permanent path
$permanentPath = sprintf(    'private/files/%s',    (string) $file->id,); Storage::disk('s3')->move(    $temporaryPath,    $permanentPath,);
В object storage move не слід сприймати як гарантовано атомарний filesystem rename. Реалізація може складатися з copy + delete, тому помилки між кроками потрібно враховувати.

Активую лише після підтвердження постійного об’єкта

Active означає, що permanent object підтверджено
if (! Storage::disk('s3')->exists($permanentPath)) {    throw new RuntimeException(        'Permanent object was not created.',    );} $file->update([    'status' => FileStatus::ACTIVE,    'path' => $permanentPath,    'temporary_path' => null,]);

У моїй моделі ACTIVE має сильне значення.

Якщо запис активний, код, який читає цей запис, може очікувати, що канонічний object path уже існує.

Тому я не ставлю active до того, як storage transition завершився успішно.

Database transaction не робить S3 частиною тієї самої транзакції

Database і object storage мають різні failure boundaries
MySQL transaction    |    +--> insert pending row    +--> update status  S3 operation    |    +--> copy object    +--> delete temporary object  These are not one atomic transaction.

DB::transaction() може відкотити SQL-зміни, але не здатний автоматично повернути назад уже виконаний S3 request.

Так само успішна storage operation не гарантує, що наступний database update завершиться.

Саме тому staging корисний: він робить проміжний стан явним і дає місце для cleanup або повторної спроби.

Не кожен temporary upload дійде до активації

Користувач може закрити вкладку, втратити мережу, передумати або ніколи не надіслати completion request.

Тоді об’єкт залишиться у tmp/.

Це не виняткова ситуація, а нормальний наслідок direct upload flow. Тому temporary namespace має мати окрему cleanup-політику.

Старі tmp-об’єкти потрібно прибирати окремо

Cleanup працює тільки з неактивованими temporary objects
tmp object created        |        vnot activated        |        volder than allowed staging window        |        vsafe cleanup

Я не хочу, щоб tmp/ перетворився на безкінечне сховище покинутих файлів.

Cleanup може виконуватися за віком об’єкта, за persisted upload state або за обома ознаками.

Важливо не видаляти об’єкт, який саме зараз проходить завершення, тому cleanup теж повинен мати чітке правило щодо станів і часу.

Я не зберігаю tmp path як фінальне посилання на файл

Якщо бізнес-сутність одразу почне посилатися на tmp/..., staging фактично перестане бути staging.

Інший код почне сприймати temporary object як постійний, а cleanup стане небезпечним.

Постійний запис має отримати canonical path тільки після успішного promotion.

Оригінальне ім’я файла не повинно визначати storage key

Ім’я, яке прийшло з браузера, корисне як metadata для користувача, але не як authoritative object key.

Сервер сам створює технічний шлях, а оригінальне ім’я може зберігатися окремим полем після нормалізації.

Це спрощує staging, запобігає конфліктам і не дозволяє клієнту керувати структурою bucket-а.

При заміні старий активний файл не потрібно видаляти до завершення нового

Безпечна заміна зберігає старий файл до кінця
old active file      |      | stays available      |new upload      |      vtmp object      |      vvalidate      |      vpromote      |      v

Якщо користувач замінює вже активний файл, я не хочу видаляти старий на початку процесу.

Новий upload може не завершитися або не пройти validation.

Старий файл видаляється лише після того, як новий уже пройшов staging і став активним.

Completion має бути безпечним для повторного запиту

Клієнт може не отримати відповідь після успішного completion і повторити запит.

Тому завершення upload не повинно безконтрольно створювати другий запис або повторно активувати той самий object path.

Persisted upload state дає точку, за якою застосунок може визначити: операція ще pending, уже active або завершилася помилкою.

Staging також робить помилки видимими

Без проміжного стану важко відрізнити покинутий upload від validation failure або помилки promotion.

Коли етапи явні, я можу окремо бачити кількість pending uploads, validation failures, activation failures і stale temporary objects.

Це важливо не тільки для debugging, а й для безпечного cleanup.

Я тестую кожну межу окремо

Перевірки для staging flow
upload request- temporary path is server-generated- upload permission targets tmp only completion- missing object is rejected- oversized object is rejected- invalid type is rejected- valid object creates pending state promotion- permanent object is created- temporary object is no longer canonical activation- record becomes active only after promotion

Мій список перевірки для temporary upload staging

Я перевіряю:

  • Чи upload спочатку потрапляє у tmp/, а не в permanent namespace?
  • Чи temporary path створює сервер?
  • Чи успішний PUT не активує файл автоматично?
  • Чи completion знову проходить через застосунок?
  • Чи перевіряється фактичний object у storage?
  • Чи є persisted pending state перед activation?
  • Чи canonical path з’являється лише після promotion?
  • Чи ACTIVE означає, що permanent object уже існує?
  • Чи stale tmp objects мають cleanup-політику?
  • Чи при заміні старий файл залишається доступним до успішної активації нового?
  • Чи completion безпечний для повторного запиту?
  • Чи код не припускає, що MySQL і S3 є однією атомарною транзакцією?

Завантаження створює об’єкт, а активація створює довіру

Direct upload добре прибирає PHP з data path, але це не повинно прибирати застосунок із процесу прийняття рішення.

Браузер може передати байти прямо у S3. Проте саме Laravel визначає temporary path, перевіряє об’єкт, зберігає стан, переводить його у permanent namespace і лише тоді активує.

Для мене tmp/ є не просто технічною папкою. Це межа довіри між "ми отримали файл" і "система прийняла цей файл".

Upload створює об’єкт. Validation і activation роблять його частиною системи.

Тимчасовий файл може фізично існувати у сховищі, але до активації він ще не повинен мати статусу постійного ресурсу системи.