В моем нынешнем проекте — "Неком Музыкальном Сервисе" (о котором я еще, наверное, не раз напишу, уж не обссудьте) — есть одно интересное требование, назвающееся "контролируемое скачивание", которое означает, что:

Это одна из тех вещей, которая отличает этот сервис от просто графического интерфейса к FTP. Я реализовал ее где-то пару недель назад, но в процессе пережил такие эмоции, что просто не могу этим не поделиться.

Пост этот — подробная история реализации фичи, нагруженная дремучими подробностями и программными частностями, и в качестве легкого чтива никак не рекомендуется :-).

Задачка

Для начала надо подробней объяснить, зачем вообще это нужно.

Ограничение скорости скачивания нужно отнюдь не для насаждения классовой вражды. Просто, если дать всем возможность скачивать на максимуме своего канала, то в наш век распространенного ADSL'а это означает, что сервер просто ляжет, и плохо будет всем.

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

Вот в этом втором пункте и кроется основное отличие этой задачи от просто выдачи HTML. Когда приложение выдает HTML, оно формирует вывод целиком в виде строки и выдает разом. Здесь так не получается, потому что, как только эта строка отдана серверу, невозможно знать, что с ней сталось после этого. Не говоря уж о том, что формировать в памяти 100 мегабайт зазипованного альбома — в принципе плохая идея :-).

Подход (теория)

Когда я брался за эту фичу, я приблизительно уже знал, в каком направлении думать.

Сам по себе веб-сервер, который запускает приложение, не заставляет его отдавать данные одним куском. Он просто сидит и ловит все, что приложение сбросит в stdout, переправляя это тут же в сеть. И с точки зрения самого простого вида приложений — CGI — весь вывод заключается в банальной последовательности print'ов. Организовать при этом контроль всего процесса очень просто: достаточно считывать данные из файла кусочками и print'ать их. Если весь файл прочитан, то клиент все получил. Если клиент закроет коннект, то на очередном print'е возникнет ошибка, которую можно поймать и тихо завершиться.

Но поскольку приложение работает не в чистом виде, а под управлением Django, CGI — это не вариант, потому что запуск фреймворка на каждом запросе будет кушать многие секунды. Django, как и подавляющее большинство других современных веб-сред, грузится в память один раз и отвечает на некоторое количество запросов, не выгружаясь. Точнее, делает он это не сам, а с помощью некого промежуточного уровня на стороне веб-сервера, который скармливает ему запросы и принимает ответы. То есть, print'ы тут не сработают.

Таких веб-серверных модулей есть много: mod_python (который рекомендован для Django), FastCGI, SCGI. И у каждого из них может быть свой способ организации ввода-вывода. Но к счастью, в питоновском мире существует стандартизованный протокол для общения приложения с любой веб-серверной средой — WSGI. Он описывает, куда приложение должно подсовывать свою функцию для обработки веб-запросов, в каком виде туда передаются параметры, и в каком виде надо отдавать ответ.

Так вот задача выдачи файла по кусочкам в WSGI организована очень по-питоновски. Приложение должно отдать веб-серверу итератор: объект, из которого веб-сервер сам будет последовательно считывать кусочки.

Питоновские итераторы

Кто знает, что такое итератор в Питоне, этот раздельчик может смело пропускать.

По-простому, итератор — это любое, из чего можно сделать последовательный выбор циклом for some_item in some_object. Например, любой список — это итератор, который отдает свои элементы.

Самое интересное, что итератором может быть совершенно любой объект, даже ваш собственный. Для этого объект должен иметь две функции: одна вызывается в самом начале цикла для всяких подготовительных действий, а другая — на каждом шаге, и возвращает результат каждой итерации.

Итреатором может быть даже не объект, а одна функция (которая называется генератором), но об этом дальше.

В самом простейшем случае в качестве такого итератора в WSGI-сервер передается список из одного элемента — строки HTML'а. Также туда можно скормить открытый файл, потому что файловые переменные в Питоне работают и как итераторы тоже.

Но мне, очевидно, надо будет создавать свой объект, который будет отдавать кусочки файла, следить за скоростью и обрабатывать окончание передачи.

Django'вский HttpResponse

Правда, вся эта теория разбивается о то обстоятельство, что сам Django не умеет (точнее, теперь уже умеет) работать с итераторами в качестве объектов вывода. Django'вский HttpResponse принимает строго строки, и ничего другого.

Когда все фичи сервиса еще только планировались, я это обнаружил, и подумал, что надо будет влезть в код Django и что-нибудь там подхачить, потому что никаких принципиальных ограничений для реализации этого быть не должно. Почти. Исторически сложилось, что для общения с mod_python (напомню: рекомендованной средой) Django не использует WSGI-интерфейс, а использует API mod_python'а. Делает, то есть, то же самое, но по-другому. Поэтому для того, чтобы реализовать итеративный вывод для Django, придется делать это дважды: для WSGI и для mod_python.

На все посвященные этому исследования я в плане (не, не так — в Плане™) отвел 8 часов чистого времени, что грубо транслируется в 2 дня. Это обычно много для одной фичи, но тут запас оправдан, потому что исследование — штука непредсказуемая. Забегая вперед, скажу, что как раз за 2 дня у меня все и получилось, но с оговорками: львиная доля времени ушла совсем на другое, длилось все 14 часов вместо 8, и день после этого я был совершенно убитым и провалялся перед телевизором. Поэтому фичу надо считать за 3 дня :-)

За дело!

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

Также пришлось подчистить немного API объекта, там было не меньше, чем три способа получить его содержимое в виде строки :-).

Однако переделать HttpResponse недостаточно, потому что выдачей себя серверу занимается не он сам, а хэндлер — модуль, который уже непосредственно работает с веб-сервером. Их, соответственно, два: wsgi.py и modpython.py. С первым все очень просто вышло, поскольку в HttpResponse лежит как-раз итератор, который и нужен в WSGI:

return response.iterator

Modpython же просит отдавать себе данные по-другому — передавая их в функцию write:

for chunk in response.iterator:
  req.write(chunk)

Вот и все различия.

На самом деле, я считаю, что мне тут очень повезло :-). Ведь вполне могло бы вдруг оказаться, что нельзя записывать несколько кусочков или что они где-то там все равно буферизуются в памяти, и тогда все усилия оказались бы просто бесполезны. Но все хорошо, что хорошо кончается.

Вся эта катавасия заняла не больше полутора часов, и я горд сообщить, что теперь этот код уже лежит непосредственно в Django.

Ограничение скорости

Вот это уже интересней. Научив Django принимать итератор, надо теперь его создать. То есть должен быть объект, к которому сервер будет периодически обращаться за новыми данными, и его задача сделать как-то так, чтобы поток этих данных был не больше определенного. По большому счету, реализовать это можно двумя способами:

После ближайшего рассмотрения первый вариант был признан более сложным, потому что там может возникнуть такая ситуация, что клиент будет забирать данные очень быстро, а значит часто, и на очередной итерации получится, что ему нужно отдать долю байта. Поэтому, чтобы не превысить скорость выдачи, опять-таки, придется ждать какое-то время. То есть реализовать еще и логику второго варианта. Кроме того, много мелких передач обычно более затратны, чем мало крупных.

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

Выглядит все очень просто:

SECOND = timedelta(seconds=1)
BANDWIDTH = 32

def output(file):
  last_time = datetime.now()

  while True:

    time_passed = datetime.now() - last_time
    if time_passed < SECOND:
      sleep((SECOND - time_passed).microseconds / 1000000.0)
    last_time = datetime.now()

    content = file.read(BANDWIDTH)
    if not content:
      break
    yield content

То есть при каждой итерации цикла, если времени прошло меньше секунды, мы просто стоим и ждем, сколько осталось до секунды.

yield

Опять таки, кто знает, что такое генератор, эту вкладку могут пропустить.

Оператор yield — это специальный return. Осбенность его в том, что он отдает значение вызывающему, но вот из функции не выходит. Точка исполнения остается на месте, и также сохраняется значение всех переменных. И когда эту функцию вызовут еще раз, выполнение продолжится с того же места в тех же условиях.

Функция с yield как раз и называется "генератором", и фактически она одна целиком реализует механизм итератора. То есть генератор output можно использовать как обычный итератор примерно так:

for chunk in output(file):
  print chunk

На самом деле технически все происходит немного не так, и подробно об этом можно почитать в документации.

К этому моменту прошло что-то около 3 часов, и я был очень рад тому, что практически все, что казалось сложным, уже реализовано.

Дополнительные требования

Одним из требований, которое я с самого начала все время забывал, было "поддержка докачки и вообще всяких download-менеджеров". Забывал, потому что оно мне не казалось особенно трудным. Я знал, что докачка в HTTP уже заложена, и за нее отвечает некий заголовок со словом "range".

Но вот когда я стал думать про докачку, я вдруг понял, что это будет означать, что к одному файлу можно открыть сразу несколько запросов к разным его кускам. А поскольку ограничение на ширину канала работает только в одном запросе, то открыв их просто побольше клиент сможет получить совершенно любую скорость. И что самое интересное, это никакая не экзотика, а именно для этого download-менеджеры все и сделаны!

Бороться с этим, очевидно, можно двояко: либо делить канал между всеми коннектами, либо разрешить только один коннект. Разделение канала мне сразу показалось чересчур сложным, потому что надо как-то синхронизировать параллельные процессы, которые не треды, и вообще никак не связаны между собой. Кроме того, каждый коннект может качать данные с разной скоростью, поэтому просто поделить ограничение на количество коннектов не получится, надо будет разрешать одним качать быстрее, если другие тормозят... Выражаясь научными терминами — геморрой.

Монопольный доступ

Значит надо придумывать монопольное скачивание. Если бы все эти коннекты были в разных тредах одного процесса, все было бы просто, потому что все уже придумано до нас: доступ к файлу заворачивается в critical section (mutex, семафор или как там их варианты еще везде по-разному называются) и все хорошо. Но запросы — это разные процессы, и память у них разная, поэтому никакого общего контролирующего объекта им не сделаешь.

Тут надо сказать, что поработать с синхронизацией процессов мне не довелось. Я слышал где-то что-то краем уха про shared memory в Windows, но что это, и как называется аналог в юниксах, и поможет ли оно вообще, понятия не имею. Если кто-то меня просветит, буду благодарен!

Lock-директории

Однако я знаю один интересный подход для синхронизации процессов, который успешно опробовал еще во время работы над "TaCo". Хоть память у процессов разная, но вот файловая система — общая. И в файловой системе любой архитектуры есть атомарные операции, которые гарантированно не могут быть выполнены одновременно двумя процессами. Это переименование файла и создание директории. То есть если два процесса захотят переименовать один и тот же файл, один из них обязательно схватит системную ошибку, потому что файла со старым именем для него уже не будет. То же самое при создании директории с одним и тем же именем.

Этот факт можно использовать для решения задачи. Процесс, перед тем, как начать что-то делать, создает директорию с условленным названием. Она работает как lock: если получилось создать — работаем дальше, если нет, вежливо говорим юзеру "403 Forbidden outta here!".

Буквально через пару тестов я осознал, что в чистом виде такой подход не работает. Если пользователь прерывает докачку, то веб-сервер, который вызывает мой итератор, спотыкается на ошибке записи в брошенный коннект и... перестает вызывать итератор и завершается. А это значит, что код итератора не знает ничего о том, что он завершился, для него это все равно, что просто ожидание следующего вызова. А раз он не знает, что завершился, он не имеет никакой возможности удалить созданный lock.

Хорошо (точнее, конечно, плохо, но ладно, солнце еще высоко). Следующая идея: если процесс завершается, значит его объекты должны удаляться. А в Питоне есть, куда написать код, который выполнится при удалении объекта (метод "__del__"). Пишу этот метод, где чищу директорию — действительно работает. Но меня беспокоит то, что этот метод вызывается не мной явно, а сборщиком мусора тогда, когда ему удобно (эта непредсказуемость — одно из главных нареканий к технологии GC вообще).

И я даже придумал, как это протестировать. Директория отлично удалялась, пока я гонял весь код под Django'вским отладочным веб-сервером, который однопроцессный, и каждый процесс там убивается сразу после выполнения запроса. А вот Apache с modpython'ом держит один процесс для нескольких запросов. И действительно, запустив систему в рабочей среде, я довольно быстро получил ситуацию, когда процесс, даже при завершении запроса без всяких прерываний, не удалял директорию еще несколько минут, пока оставался жив.

Контрольным выстрелом в голову технологии с lock-директорией оказалось то, что если Apache перестартовать во время закачки, он обрывает все процессы моментально, и никаких сборок мусора не вызывается вообще. То есть получится, что если надо будет зачем-то передернуть сервер, то все пользователи, качающие файлы, лишатся этой возможности делать это дальше вообще :-). Можно, конечно, попросить админа после перезапуска чистить файлы каким-нибудь скриптом... но я, кажется, догадываюсь, что админы обычно в таких случаях отвечают :-)

Временные файлы

Следующая попытка решить проблему — временные файлы. Вообще, для меня стало большим откровением, когда я стал программировать под юникс, как там принято обходиться с временными файлами. Процесс может открыть файл и тут же его удалить! Из файловой системы он исчезнет, но "мясо" его останется доступным программе. А только когда процесс завершится, причем любым способом, даже безусловным kill'ом, система удалит файл реально.

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

Я уже не могу подробно вспомнить те мучения и бесконечные эксперименты, когда я пытался придумать хоть какой-нибудь обходной путь. Пытался комбинировать директории и файлы, использовать то, что непустая директория не удаляется, прописывать что-то не в файловую систему, а в БД... Все в итоге свелось к тому, что как lock не организовывай, будет ситуация, когда процесс его не удаляет.

Исход

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

Она заключалась в том, что ограничение закачки одного файла одним запросом, вообще-то, проблему не решит. Ведь пользователь может начать качать несколько файлов сразу (какое прозрение!) Просто поставить на загрузку сразу 15 альбомов. А это значит, что та проблема, о которую я расшибаюсь уже несколько часов кряду, не только исключительно трудно решаема, но и решение будет совершенно бесполезным :-(.

Просто так взять и поставить крест на целом дне напряженной работы трудно. Поэтому я придумал "гениальное" решение: расширить задачу дальше, а именно, установить запрет коннекта не на один файл, а на пользователя целиком. Да, возможно это странно, но если в общем и целом никому не помешает, то почему бы и нет?

Однако насчет "никому не помешает" у меня были сомнения и я, собравшись с мыслями, решил позвонить заказчику, чтобы узнать, что он думает по этому поводу. Я предложил три варианта поведения системы в порядке сложности реализации:

  1. Пользователю можно качать только один файл в одно время.

  2. Пользователю можно качать несколько файлов, но без общего ограничения ширины коннекта (я таки еще надеялся, что залочить один файл мне удастся).

  3. Пользователю можно качать сколько угодно, но ограничение коннекта должно быть для него глобальным (это та балансировка скорости, которую я отринул в самом начале).

Конечно, первый вариант не мог никому понравиться, но мне было нужно знать, сойдет ли оно в принципе, если по-другому не выйдет.

Не сошло. Думая о download-менеджерах и личерах, которые жадно качают с сервера терабайты в секунду с помощью распределенных сетей анонимизирующих прокси, я как-то совсем забыл (а заказчик мне напомнил) о самых обычных пользователях с браузером Internet Explorer, которые просто выбирают "Сохранить как" из меню для каждого файла и идут заниматься другими делами. Браузер открывает 15 окошек и тихо себе качает. Так вот первый вариант означал бы, что они не могут открыть 15 окошек. Им надо сидеть у компьютера, следить за каждым файлом, и стратовать следующий, когда закончит качаться предыдущий. Даже в своем изможденном состоянии мой мозг тут же воспринял такой "сервис", как совершенно неприемлемый.

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

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

Балансировка

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

Непосредственно решение состояло из двух частей.

Во-первых, я решил, что в системе где-то должно быть место, которое знает, во сколько коннектов сейчас что-то качает отдельный пользователь. Раз это пользователь, то идельаное место для хранения этого знания — это сама модель (табличка) пользователя. Там будет некое поле, в котором будет записано число текущих коннектов.

Но опять встает мой вчерашний вопрос: кто будет уменьшать это число, если коннект может быть убит внезапно? И вот тут я придумал вторую часть решения:

И этот подход решил сразу вообще все проблемы!

Главное, не надо ничего уменьшать: если коннект сдох, он просто перестает увеличивать счетчик, и со следующей итерации все коннекты в курсе, что их стало меньше. Также решается и вопрос с балансировкой между конннектами разной скорости. Если какой-то медленный коннект не успел докачать данные за секунду, остальные, не увидев увеличения, тут же дадут данных побольше.

Все вышло настолько хорошо, что мне даже перестало быть обидно за предыдущий день. Ничто так не улучшает настроение и работоспособность, как пофиксенный сложный баг (и обед!)

Докачка

Теперь настало время реализовывать докачку. Клиент присылает в запросе заголовок Range, в котором написано, с какого и по какой байт нужны данные. Остается только подвинуть текущий указатель в файле до нужного места и делать цикл до указанного второго числа.

Сложностей я нашел только две:

Если кого заинтересует, вот текст функции, которая парсит один диапазон и возвращает точные значения смещений начала и конца диапазона.

def parse_range(http_range, file_size):
  bits=http_range.split('-')
  if bits[0]: # normal range
    first = int(bits[0])
    last = bits[1] and int(bits[1]) or file_size-1
  else: # suffix range
    first = file_size - int(bits[1])
    last = file_size - 1
  return (first, last)

Обратите внимание, что "last" — это последний считываемый байт, поэтому размер куска считается как last - first +1.

Что на самом деле интересно, так это то, что с наличием докачки усложняется определение скачанности файла до конца. Ведь в одном коннекте качается только одна часть, и он ничего не знает о том, есть ли другие. Поэтому надо где-то хранить состояние всех скачанных на данный момент кусков, чтобы можно было их сложить и увидеть, сколько байт скачано.

Каждый скачиваемый альбом у меня, опять же, присутствует в виде таблички, в которую состояние его скачанности и складывается. Представляется оно просто списком неперекрывающихся скачанных диапазонов. Каждый коннект, когда выдает очередной кусок, вызывает метод таблички, который сливает новый диапазон со списком существующих.

Вот это слияние тоже вызвало некоторые затруднения. Нужно получить такой эффект:

[(0,100), (200,300)] + (50, 250) = [(0, 300)]

Я подумал, что обязательно есть какой-нибудь алгоритм, как это делать, но поискав его минут 20, ничего не нашел. (Но мне все еще интересно, если кто знает, поделитесь, пожалуйста!) В итоге, написал свой:

def merge_range(new_range, ranges):

  def is_intersected(range1, range2):
    return (range1[1] >= range2[0] and range1[0] <= range2[1])

  def merge(range1, range2):
    return (min(range1[0], range2[0]), max(range1[1], range2[1]))

  new_ranges=[]
  for range in ranges:
    if is_intersected(new_range, range):
      new_range = merge(new_range, range)
    else:
      new_ranges.append(range)
  new_ranges.append(new_range)
  new_ranges.sort()

  return new_ranges

Пользуйтесь!

Да... Для хранения этого списка в базе не делается никакой тучи зависимых таблиц, он просто сериализуется в строку стандартным pickle'ом.

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

Треки

У нас предусмотрена возможность скачивать не только альбом целиком, но еще и отдельно по трекам. И желательно определять факт скачанности и для этого случая тоже. Засада в том, что сумма размеров треков не равна размеру альбома. Потому что альбом у нас — это zip-архив, в котором есть еще и текстовый файлик с описанием треков и кавер-картинка альбома. А треки — просто файлы.

Я поступил просто. Для каждой закачки хранятся все списки диапазонов: один для альбома, и по одному для каждого трека. И при определении скачивания я складываю альбомный прогресс, трековые прогрессы и смотрю на тот, который больше. То есть человек может начать качать альбом, потом бросить, закачать по трекам, и все посчитается правильно.

Авторизация по HTTP

Это был последний вопрос, который оставалось решить. Поскольку сервис у нас мегасекретный (оттого я и не ставлю нигде на него ссылок :-) ), вся работа с ним идет только по авторизации. А большинство download-менеджеров, насколько я знаю, пользуются именно HTTP-авторизацией (хотя некоторые, вроде, умеют читать браузерные cookies, но это зло).

HTTP-авторизация оказалась очень простым делом (я вообще все больше люблю HTTP). Пример найти в сети довольно легко, но я для полноты изложения приведу кусочек кода, который это делает, в виде Django'вского фильтра запроса (middleware):

from django.http import HttpResponse, HttpResponseForbidden

class HTTPAuthMiddleware:
  def __init__(self):
    self._auth_required = HttpResponse()
    self._auth_required.status_code = 401
    self._auth_required['WWW-Authenticate'] = 'Basic realm="your site name"'

    self._forbidden = HttpResponseForbidden('Forbidden')

  def process_request(self, request):
    if not request.user.is_anonymous(): # юзер уже авторизован
      return

    # чтение HTTP-заголовка
    authorization = request.META.get('HTTP_AUTHORIZATION','')
    if not authorization:
      return self._auth_required

    # раскодируется логин и пароль
    from base64 import b64decode
    username, password = b64decode(authorization[6:]).split(':')

    # юзер ищется в базе и проверяется его пароль
    from django.contrib.auth.models import User
    try:
      user = User.get(username__exact=username, is_active__exact=True)
    except User.DoesNotExist:
      return self._forbidden
    if not user.check_password(password):
      return self._forbidden
    request.user = user

Это только один из вариантов, причем очень "тупой": он не пускает вообще никого никуда без авторизации. Что он должен делать на самом деле, зависит от приложения.

Финал второго дня

Итак, фича была реализована, баги побеждены, скачивание оттестировано. Скорость действительно держится на заданном уровне, сколько бы коннектов ни открывал download-менеджер. После скачивания альбом удаляется из корзины и записывается в историю закачек на сайте.

Кстати, специально для тестирования я скачал shareware'ный "ReGet Junior". Качает он хорошо, но одна вещь в нем меня просто вывела из себя, напомнив, почему я не люблю общую "культуру" в производстве софта для Windows.

На виндовой машине у меня для всего скачиваемого есть одна папочка C:\Download. Туда качается все и отовсюду с помощью разных программ. ReGet при установке спросил, куда я хочу складывать все файлы — это замечательно. Но никто не просил его изменять иконку папки на свой логотип.

Уважаемые авторы ReGet'а, не надо так делать. Это не помогает пользователю отыскать эту папку, потому что для большинства пользователей Windows и так уже помечает ее сама. И это раздражает остальных пользователей, потому что мой компьютер — это не ваша рекламная площадка. AOL вела себя также с Netscape'ом, посмотрите, где теперь Netscape.

Вот. Поругался.

Баги

Конечно, не обошлось без багов. Через некоторое время выяснилось, что при скачивании файла браузером окончание процесса фиксируется нормально, а вот при скачивании download-менеджерами файл остается в корзине и не попадает в историю.

Выясняя, как это случается, я нашел в логах Апача сообщение от Питона о том, что "IOError, write failed". По идее, это происходит тогда, когда клиент неожиданно закрывает коннект, и сервер действительно падает на попытке отправить туда очередной кусок. Но меня смутило, что эта ошибка происходила и тогда, когда скачивание проходило нормально, без всяких обрывов.

Поймав этот exception, я слогил количество запрошенных байтов и переданных, и увидел, что они вообще никогда не совпадают. Происходит это потому, что download-менеджер запрашивает для этих потоков перекрывающиеся куски файлов (что, кстати, совершенно легально). И получается, что если куски качаются с примерно одинаковой скоростью, каждый из них оборвет закачку раньше, чем примет все запрошенное.

В этом и была ошибка: я проверял завершенность закачки только после того, как передача успешно завершилась, а как я уже рассказывал, этот момент надежно получить нельзя.

Тогда я просто стал проверять завершенность скачивания на каждой итерации. Опять не помогло, но уже по другой причине. Вот псевдокод того, как это делалось:

  while True:
    content = file.read(...)
    yield content
    download.update_progress(...)

Я, как приличный программист, сначала выдаю данные, и только после того, как они переданы, уведомляю об этом закачку. Но выходит, что на самом последнем куске, когда клиент обрывает закачку, сервер ловит ошибку и не вызывает дальше мой итератор, а значит до кода после yield в последний раз дело не доходит, и последний кусок не регистрируется.

И хоть я очень не люблю решения в духе "ладно, это все равно редко", я решил эту задачку, просто передвинув update_progress выше yield, то есть до реальной отправки куска. А значит может возникунть ситуация, когда закачку я уже посчитал завершенной, а клиент последний кусочек получить не смог по каким-то причинам.

На самом деле, это не так плохо. Во-первых, регистрация после отправки все равно ничего на 100% не гарантирует: между сервером и клиентом может, например, стоять какой-нибудь умный прокси, который может принять данные у сервера, но до клиента их не доставить. А во-вторых, случай, когда что-то плохое случилось именно на последних 32 КБ действительно редок. И максимум неудобств, которые грозят клиенту — это необходимость добавить альбом на скачивание еще раз и докачать этот последний кусок. Это гораздо лучше, чем необходимость чистить корзину вручную в подавляющем большинстве случаев :-).

И вот, уже где-то неделю больше багов не наблюдается.


Такая, вот, долгая история... Спасибо всем (всем троим), кто сюда дочитал, и надеюсь кому-нибудь кусочки кода окажутся полезными :-).

P.S. У этой истории появилось продолжение.

Комментарии: 36 (особо ценных: 1) (feed)

  1. kropp

    Спасибо за подробный рассказ. Если вдруг понадобится что-нибудь такое написать, я буду знать от чего отталкиваться.

    Только вот самому описывать реализации нетривиальных вещей всегда лениво… Хотя это могло бы кому-нибудь помочь.

  2. Sergey Petrov

    Ох, прямо психологический триллер :)

    Тебе через год впору будет издавать блог в бумажном виде :) Будет продаваться.

  3. Vitaliy

    Забукмаркнул :)

    А shared memory - лучшее решение это <a href="http://www.danga.com/memcached/" rel="nofollow" title="memcached">memcached</a>. Питоновское API есть, и он кстати рекомендуется как cache backend для Django. Довольно полезная штука, особенно когда дело дойдет до load balancing нескольких серверов.

  4. Иван Сагалаев

    Memcached скорее всего будет. Например сейчас download'ы каждую секунду пишут данные в табличку, и мне сдается, что это сильно напряжет базу при первой же нагрузке. Поэтому они наверняка будут жить именно в memcached.

  5. Elf

    Проблема решалась не с той стороны :)

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

    Сложности могут начаться, повторюсь, когда разным юзерам нужно будет отдавать файло на разной скорости. Решить это можно, к примеру, генерацией ссылок на разные хосты для разных юзеров. Хосты могут быть виртуальные, типа нескольких ip на одном реальном компе. И QoS снова начнет работать как надо.

    Ну а вообще, рекомендую ознакомится с линуховым IPC. Там всё достаточно просто. В питоне есть стандартный интерфейс к ним, в консоли можно им же управлять. То бишь, сбросить флаги, почистить шаренную память скриптом при перезапуске сервера - вполне реально. И именно админ должен этим заниматься. Отмазка "но я, кажется, догадываюсь, что админы обычно в таких случаях отвечают" прокатывает, только на виртуальном/шаред хостинге. На выделенном сервере такой админ заменяется на более квалифицированного :-)

  6. Igor Artamonov

    Хорошо написано :)

    Насчет геометрических операций: это судя по всему Postgres может.

    И еще, такая же basic авторизация похоже есть в django.contrib.auth.handlers.modpython :)

  7. Иван Сагалаев

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

    А что такое "один комп"? Если IP, то как быть с разными юзерами из-за одного NAT'а? И как с регистрацией закачки?

    Любая задача обычно оказывается куда сложнее, когда начинаешь ее делать :-). Статья была и об этом тоже.

    И именно админ должен этим заниматься. Отмазка “но я, кажется, догадываюсь, что админы обычно в таких случаях отвечают” прокатывает, только на виртуальном/шаред хостинге. На выделенном сервере такой админ заменяется на более квалифицированного :-)

    Я не исповедую такой подход...

  8. Иван Сагалаев

    Насчет геометрических операций: это судя по всему Postgres может.

    Интересно. А есть ссылочка почитать?

    И еще, такая же basic авторизация похоже есть в django.contrib.auth.handlers.modpython :)

    Оттуда и взято частично :-).

  9. Фрост

    Меня всё-же смущает последний абзац, где говорится, что вдруг ежели чего-то недокачается, то пользователю достаточно добавить файлик в корзину ещё раз. Всё это хорошо работает до тех пор, пока мы не берём с пользователя денег за акт добавления файлика в корзину (как это делает allofmp3.com, например).

  10. Лёхха

    Хорошая статья, почитал на одном дыхании :))
    Последня фраза

    А во-вторых, случай, когда что-то плохое случилось именно на последних 32 КБ действительно редок.

    меня частенько достаёт. Причем подчеркиваю — <b>частенько</b>. Файл 190 метров (для факта - коннект 320кбит)... последние 30-5 байтов менеджером закачки не качаются. при учёте что ссылки берутся типа с rapidshare и подобных, это сильно напрягает при наличии ограничений на скачивание с одного ипа в сутки. В чём косяк - я так и не понял. Может менеджер поменять?

  11. Elf

    А что такое “один комп”? Если IP, то как быть с разными юзерами из-за одного NAT’а? И как с регистрацией закачки?

    А это их половые проблемы. Если задача - облегчить жизнь серверу, то именно в таком ракурсе она и должна решаться.

    Регистрация закачек - тоже излишне усложнено, думаю. Если уж юзер начал качать, то он файл докачает. Процент ложных закачек будет достаточно мал, думаю.

  12. Elf
        <blockquote><blockquote>И именно админ должен этим заниматься. Отмазка “но я, кажется, догадываюсь, что админы обычно в таких случаях отвечают” прокатывает, только на виртуальном/шаред хостинге. На выделенном сервере такой админ заменяется на более квалифицированного :-)<p /></blockquote>
    

    <p>Я не исповедую такой подход…</p></blockquote> <p>Подход "разделение обязанностей" себя оправдывает ;-)</p>

  13. Иван Сагалаев

    Насчет последних байтов.

    Я тоже часто наблюдал сложности с закачкой файлов, но именно в файлообменных сетях. Но возьмусь сказать, почему именно, но связано это там, как я читал, с тем, что менеджер пытается качать с нескольких хостов, разделяя между ними остаток файла. Здесь же ситуация другая: один хост, доступность которого не скачет туда-сюда по ходу скачки. Поэтому мне думается, что ситуации эти будут редки.

    Насчет денег вопрос, в общем-то, тоже решается. Например просто письмо администраторам с объяснением ситуации и просьбой организовать отмену "скачанности". Или автоматическая возможность докачать любые, скажем, 128К из альбомов, скачанных за последний день... Варианты есть. Конечно хорошо бы, чтобы скачка была всегда надежной, но как я написал, это, похоже, не во власти сервера.

  14. Elf

    Насчет последних байтов.

    Я тоже часто наблюдал сложности с закачкой файлов, но именно в файлообменных сетях.

    Там проблема вполне себе адекватная. На примере осла/торрента:
    У файла имеется общая контрольная сумма и контрольные суммы блоков. Данные качаются случайно, из разных источников. Один и тот же файловый блок может быть из разных мест. На момент скачки нужного количества байт для полного файла может оказаться, что часть блоков битая. Соответственно, осёл летит перезакачивать битые блоки. На плохом коннекте и больших сложных файлах оверхед бывает очень большой. Соответственно, 99% волшенбным образом замирают на длительное время.

    Со скачкой обычных файлов по ftp/http проблем не испытывал практически никогда. Если что и было - обычно глюки клиентов. Опера версии до 8-й, к примеру, большие файлы (размером с CD) качать совершенно не любила.

  15. Eugene Lazutkin

    Хорошая статья показывающая решение реальной задачи, которая злободневна в разных контекстах. Хорошо, что на русском —- русскоязычные смогут прочитать и приобщиться. Плохо, что не на английском —- большая часть мировой аудитории потеряна, а интересна она была бы всем.

  16. Иван Сагалаев

    Ну должен же хоть кто-то писать материалы на русском :-). А то так и останемся страной PHP и windows-1251.

    Вот я знаю один блог, в том числе и про Django, который ведет человек замечательно говорящий на обоих языках. Fancy to translate? :-)

  17. dp_wiz

    Очень очень очень интересная статья. Спасибо огромное.

    Было б интересно посмотреть реализацию подобного у конкурентов (turbogears etc).

  18. Julik

    Интересная статья, но на ограничение по полосе я бы таки смотрел с точки зрения какого-нибудь модуля для Traffic shaping. Запихивать это в само приложение мне кажется несколько черезмерным.

    Надо подумать как такое провернуть на Рельсах, по идее там есть send_file do...end :-)

  19. Олег Шимчик

    Спасибо за интересную статью. И, судя по комментариям, дочитало ее значительно больше, чем три человека. ;-)

  20. Gevara

    Мне жаль "нерусских" - уже не первый месяц мы наслаждаемся отличными статьями про Джанго, а иноязычная публика не имеет ни малейшего понятия, что она (что-то) упускает. Хотя с другой стороны - пусть русский учат.

  21. Max Ischenko

    Читать описание мыслительного процесса очень интересно, вне зависимости от конкретных деталей задачи.

    Elf: решение при помощи QoS мимо, т.к. решает задачу на уровне TCP/IP, а не на уровне приложения.

  22. [...] Маниакальный Веблог » Контролируемое скачивание — интересно описан процесс поиска решения [...]

  23. Elf

    Max Ischenko, а нафиг оно надо - решать на уровне приложения?

    Ограничение скорости скачивания нужно отнюдь не для насаждения классовой вражды. Просто, если дать всем возможность скачивать на максимуме своего канала, то в наш век распространенного ADSL’а это означает, что сервер просто ляжет, и плохо будет всем.

    Где тут про требования уровня приложения? Это чисто сисадминское занятие - разгружать сервер.

  24. Elf

    P.S. 2Иван Сагалаев

    Предлагаю подумать про разгрузку сервера на уровне приложения в условиях load-balancing кластера ;-)

  25. Иван Сагалаев

    Elf, это как раз Вы предлагаете решение, не зная требований. Пост в блог, очевидно, не стоит считать точной спецификацией. Макс в этой ситуации просто описал одну из ситуаций, когда Ваше решение не проходит, не предлагая своего (как и делает любой нормальный инженер в отсутствии ТЗ).

    В любом случае, Ваша точка зрения понятна еще с первого раза :-)

  26. Elf

    В любом случае, Ваша точка зрения понятна еще с первого раза :-)

    Намек понят. Извиняюсь :)

    Так редко общаюсь со специалистами, что постоянно приходится использовать язык телепузиков - повторять всё по 3 раза разными голосами...

  27. [...] Ладьненько, идем дальше… А дальше Иван Сагалаев. Статью “Контролируемое скачивание” читать всем, кто занимается разработкой движков сайтов или сервисов для них! [...]

  28. Дежурный

    Особо ценный комментарий

    Формат диапазона — это на самом деле два формата. Если написано “200-299″, то это 100 байт со смещения 200 от начала. А вот если написано “500-”, то это 500 байт не сначала, а с конца (ну и до конца).

    • The final 500 bytes (byte offsets 9500-9999, inclusive): bytes=-500
    • Or bytes=9500-
  29. Alexander Solovyov

    А вот если написано “500-”, то это 500 байт не сначала, а с конца (ну и до конца).

    Судя по

    The final 500 bytes (byte offsets 9500-9999, inclusive): bytes=-500

    То 500 байтов с конца - это "-500", а не "500-", которые именно с 500-ого байта и до конца.

  30. Иван Сагалаев

    Да-да, предыдущий комментарий о том же. Я ошибся, и теперь уже исправил.

  31. vlad

    Очень интересная статья, заставляет шевелить серым веществом. Спасибо.

    Кстати, есть ли открытые проекты для некомерческого использования подобного рода? Хочу сделать файлопомойку (видео/mp3) на радость юзерам с проставлением тегов.

  32. Иван Сагалаев

    А это хороший вопрос... Сам я ничего про такое не знаю (почему и стал писать). Я сначала думал сам выдрать и опубликовать эту штуку в виде отдельной библиотеки. Остановило то, что непонятно, где разделять специфику конкретного приложения и общую часть: выдерешь слишком много, будет бесполезно для других нужд, слишком мало — не будет отличаться от просто HTTP-сервера.

    Другими словами, мне кажется, что еще просто не стал момент для того, чтобы появились удачные полезные библиотеки такого рода, еще не выработались паттерны проектирования.

  33. [...] то пускай пользуются, но под контролем. И два поста Контролируемое скачивание и Контролируемое скачивание 2 посвящены реализации [...]

  34. gruz0

    После прочтения статьи появилось желание выучить Python :)

    Спасибо автору!

  35. Sergey Kishchenko

    Не читал еще следующую статью, возможно, там уже баг с легальным exceptionом во время закачки пофикшен. Хотел бы предложить более надёжное решение(слегка в Erlang стиле). Перед yield'ом spawn'им отдельный процесс, который ждёт сигнала от основного процесса определённое кол-во времени, а потом делает updateprogress. Случаи таймаута можно логировать, т.о. возможно предугадывание проблем с закачкой. Данное решение не является идеальным, однако всё же надежней, да и логически более правильно. Огромный минус - сложно будет добиться нужной производительности - всё же не Erlang.

    Спасибо за интересное изложение материала!

  36. Виталий Колесников

    Не могу сейчас проверить, но, по-моему, есть такой модуль — gc — как раз для управления "сборщиком мусора". Это к тому, что Иван сетовал на его неуправляемость. ;)

Добавить комментарий

Format with markdown