Конвейер загрузки
Что происходит между вызовом upload() и полученным File: какой путь выбирается, когда
считается хеш, как переживается обрыв посреди части и почему одна ситуация заканчивается
честным «не знаю». Всё ниже — по коду Api\Files и Api\Uploads.
Выбор пути
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.
Хеш и дедупликация
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 не отправляет хеш с опциями в любом случае, поэтому работает правильно и со старыми
серверами.
Цикл частей
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():
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(), ошибки отмены игнорируются) и бросает исходное
исключение.
Сборка файла
для попытки 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()
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()работает только с файлом: после перезапуска процесса байтов из памяти уже нет.