Пакет · sdk-s5w

Конвейер загрузки

Что происходит между вызовом upload() и полученным File: какой путь выбирается, когда считается хеш, как переживается обрыв посреди части и почему одна ситуация заканчивается честным «не знаю». Всё ниже — по коду Api\Files и Api\Uploads.

Выбор пути

text
files()->upload($path) / uploadContent($bytes)
      │
      ├─ размер ≤ chunkThreshold (16 МиБ) ──► POST /v1/files, multipart, один запрос
      │                                        таймаут transferTimeout, без повторов
      │
      └─ размер > chunkThreshold ───────────► uploads()->sendSource()  (см. ниже)

Почему порог 16 МиБ, а не 32. Сервер принимает тело запроса до 32 МиБ, и одним запросом можно было бы отправлять файлы почти такого размера (Files::SINGLE_REQUEST_LIMIT — 32 МиБ минус 64 КиБ на служебную часть multipart). Но обрыв multipart-запроса на 29-м мегабайте теряет всё, а части продолжаются с места обрыва. На файлах больше 16 МиБ три запроса вместо одного незаметны, а устойчивость к обрывам заметна. Так же поступает панель S5W. Порог настраивается в S5wConfig::$chunkThreshold, но не выше SINGLE_REQUEST_LIMIT.

Почему один запрос не повторяется. Если запрос дошёл и сервер сохранил файл, а оборвался только ответ, повтор создаст второй файл — name (1).ext. Отличить «не дошло» от «дошло, но ответ потерян» по оборванному соединению нельзя, поэтому SDK не рискует: для этого запроса retries из конфигурации не действует.

Источник байтов

Внутри файл с диска и строка из памяти представлены одним объектом Support\Source. Файл читается по одной части: fseek() на нужное смещение и fread() нужной длины. В памяти одновременно держится не больше одной части (8 МиБ), сколько бы весил файл.

Перед стартом размер файла фиксируется (filesize() после clearstatcache()). Если файл укоротится во время загрузки и чтение вернёт пустую строку раньше времени, SDK отменит сессию и бросит S5wException с текстом the file ended at … — it changed during the upload.

Хеш и дедупликация

text
deduplicate && нет imageOptions ?
  да  → sha256(файл) ── передаётся в start()
  нет → без хеша

POST /v1/uploads {name, size, folder, sha256?, imageOptions?}
  │
  ├─ такие байты в бакете уже есть ──► сервер сразу создаёт файл
  │                                    ответ: file != null, id = "", chunkSize = 0
  │                                    SDK: onProgress(size, size) → вернуть file
  │
  └─ нет ──► сессия {id, offset: 0, chunkSize: 8 МиБ}
             SDK: onStart(session) → цикл частей

Хеш считается hash_file() потоком, без загрузки файла в память. Он нужен дважды: в start() — чтобы сервер мог найти готовое содержимое, и при complete() — сервер сверяет его с тем, что получил, и отклоняет файл при расхождении (ValidationException).

start() выполняется без повторов, даже если в конфигурации retries > 1: с хешем этот запрос может сам создать файл, и повтор после потерянного ответа создал бы второй.

Дедупликация и опции картинок

С imageOptions хеш не считается и не отправляется. Причин две.

Хеш ничего не скажет. При обработке в бакет попадает результат, а не оригинал. Хеш оригинала с ним не совпадёт никогда, так что дедупликация не сработает, а время на подсчёт уйдёт.

Старые серверы ошибались. До S5W 1.1.1 сервер при совпадении хеша оригинала с уже лежащим в бакете необработанным файлом создавал новый файл на этом оригинале и молча пропускал опции: вместо photo.webp 300×200 получался photo (1).png в исходном размере, с processed: null. В 1.1.1 это исправлено — сервер с опциями обработки идёт обычным путём. SDK не отправляет хеш с опциями в любом случае, поэтому работает правильно и со старыми серверами.

Цикл частей

text
reported = offset; onProgress(offset, size); failures = 0

пока offset < size:
  chunk = source.read(offset, min(chunkSize, size - offset))
  chunk пустой?            → abort, S5wException (файл изменился)

  PATCH /v1/uploads/{id}   Upload-Offset: offset, тело — байты части
      200                  → новая сессия
      сеть / 5xx / 409     → recover()            (см. ниже)
      прочий отказ         → abort, бросить        (данные неверны, повтор не поможет)

  offset вырос?            → reported = offset; failures = 0; onProgress(offset, size)

finish()

Части читаются от смещения сервера, а не от счётчика в SDK. Поэтому любой ответ — обычный, после восстановления, после resume() — автоматически ставит чтение туда, где сервер ждёт следующий байт.

Заголовок Upload-Offset превращает возможную порчу файла в обычную ошибку: если сервер уже получил эту часть (её ответ потерялся), он ответит 409, а не приклеит её второй раз.

Что считается прогрессом. onProgress вызывается, когда смещение выросло — неважно, узнали мы это из ответа на часть или из status() после восстановления. И только тогда счётчик неудач сбрасывается: три неудачи подряд прерывают загрузку, а неудачи, между которыми было продвижение, — нет.

Восстановление

Когда отправка части оборвалась (TransportException), сервер ответил 5xx или 409, вызывается recover():

text
failures += 1
failures ≥ ATTEMPTS (3)?  → error в лог, UploadInterruptedException(id, offset, size, причина)

пауза 1000 × 2^(failures-1) мс        1 с, затем 2 с
warning в лог

GET /v1/uploads/{id}
  ответил        → debug в лог «server holds X», вернуть сессию сервера
  не ответил     → вернуть прежнюю сессию — следующая попытка покажет

Главное здесь — спросить сервер, а не просто повторить часть. Обрыв соединения не говорит, дошла ли часть: она могла целиком лечь на сервер, а потеряться только ответ. Повторная отправка той же части дала бы 409; отправка следующей — тоже 409. status() даёт точный ответ, и цикл продолжается с правильного места.

Проверено имитацией: при «потерянном ответе» на первую часть status() вернул смещение 8 388 608 — часть была на сервере, и SDK сразу перешёл ко второй, ничего не отправив повторно.

Паузы короткие и растут: короткий сбой проходит за секунду, а при недоступном сервере загрузка прерывается примерно через три секунды, не зависая надолго. Сессия при этом не отменяется — её можно продолжить.

Почему отмена на других отказах: 400, 413, 404, 422 говорят о самих данных — часть не того размера, превышен заявленный размер, сессии нет. Повтор тех же байтов дал бы тот же ответ, поэтому SDK освобождает сессию (abort(), ошибки отмены игнорируются) и бросает исходное исключение.

Сборка файла

text
для попытки 1..ATTEMPTS:
  POST /v1/uploads/{id}/complete      таймаут transferTimeout
      201                  → debug «completed», вернуть File
      отказ 4xx            → abort, бросить        (хеш не совпал и т.п.)
      сеть / 5xx           → GET /v1/uploads/{id}
                                404           → UploadUnconfirmedException
                                сессия жива   → пауза, ещё попытка
                                не ответил    → пауза, ещё попытка
  попытки кончились       → UploadInterruptedException

Потерянный ответ на complete

Это единственное место, где SDK не может узнать правду.

Сборка файла — последний запрос. Если ответ на него потерялся, SDK спрашивает сервер о сессии:

  • сессия жива — сборка не прошла, можно повторить. Обычный случай: запрос не дошёл;
  • сессии нет (404) — значит, сервер её закрыл. Обычно это означает, что файл собран, но при ошибке сборки сервер тоже удаляет сессию. Что именно случилось, по сессии уже не понять, а id созданного файла был только в потерянном ответе.

Во втором случае SDK бросает UploadUnconfirmedException с id сессии и именем. Искать файл самому — по имени в папке — SDK не стал бы: в папке может лежать другой файл с тем же именем, загруженный кем-то ещё, и вернуть его было бы хуже, чем честно сказать «не знаю». Проверено имитацией: в этом сценарии файл действительно оказался в папке.

Продолжение: resume()

text
resume(id, file)
  GET /v1/uploads/{id}              404 → NotFoundException (сессии нет — начать заново)
  размер файла ≠ size сессии        → S5wException (не тот файл)
  debug «resumed at offset»
  цикл частей с offset сервера      (те же повторы и восстановление)
  finish()

Если сервер уже получил все байты (например, прервалась именно сборка), цикл не отправит ни одной части и сразу перейдёт к complete().

resume() проверяет только размер, а не содержимое: подменённый файл того же размера будет дописан. Защита от этого — хеш: если он передавался при старте (по умолчанию так и есть, кроме загрузок с imageOptions), сервер отклонит файл при сборке.

События в логе

Момент Уровень Сообщение
дедупликация debug upload deduplicated: {name} ({size} bytes) → file {file}, nothing sent
сессия открыта debug upload {id} started: {name}, {size} bytes in chunks of {chunk}
повтор части warning upload {id}: chunk at {offset} failed (attempt {n}/3), retry in {ms} ms: {error}
восстановлено debug upload {id}: the server holds {offset} of {size} bytes, continuing
попытки кончились error upload {id} interrupted at {offset} of {size} after 3 attempts: {error}
данные отклонены error upload {id} refused at {offset}: {error}
продолжение debug upload {id} resumed at {offset} of {size} bytes
повтор сборки warning upload {id}: completing failed (attempt {n}/3), retry in {ms} ms: {error}
сборка не подтверждена error upload {id} completed but not confirmed: {error}
готово debug upload {id} completed: {name} → file {file}

Плюс строка на каждый HTTP-запрос — см. HTTP и логирование.

Чего конвейер не делает

  • Параллельных частей. Сервер принимает части строго по порядку, поэтому параллельность ничего бы не дала.
  • Настраиваемого числа попыток. Uploads::ATTEMPTS = 3 — константа.
  • Продолжения данных из памяти. resume() работает только с файлом: после перезапуска процесса байтов из памяти уже нет.