Содержание
В отличии от многочисленных систем контроля версий концепция Mercurial достаточно проста, чтобы понять, как в действительности работает программа. Эти знания, безусловно, не являются необходимыми, но я считаю, что полезно иметь «мысленную модель» того, что происходит.
Понимание того, что происходит за кулисами, приводит к пониманию, что Mercurial тщательно спроектирован для безопасной и эффективной работы. И что не менее важно, если иметь представление о том, что делает программа, когда я выполняю какую-то задачу по контролю версий, меня не будет удивлять её поведение.
В этой главе мы сначала узнаем о внутреннем устройстве Mercurial, а затем обсудим интересные детали его реализации.
Когда Mercurial отслеживает изменения файла, он сохраняет историю этого файла в объекте метаданных называемом filelog. Каждая запись в filelog содержит достаточно информации чтобы восстановить одну ревизию отслеженного файла. Filelog'и хранятся в виде файлов в папке .hg/store/data
. Они содержат два вида информации: данные о ревизиях и индексы, помогающие Mercurial эффективно искать ревизии.
Для большого или имеющего длинную историю файла filelog хранится раздельно в файлах с данными (расширение «.d
») и с индексом (расширение «.i
»). Для маленьких файлов с небольшой историей ревизионные данные и индекс хранятся в едином файле с расширением «.i
». Связь между файлом в рабочей директории и filelog'ом, который отслеживает его историю в хранилище, показана на Рисунок 4.1, «Связь между файлами в рабочей директории и filelog'ом в репозитории».
Для сбора и хранения информации об отслеживаемых файлах Mercurial использует структуру называемую манифест. Каждый раздел в манифесте содержит информацию о файлах, входящих в один набор изменений. В разделе записано, какие файлы присутствуют в наборе изменений, ревизия каждого файла и некоторые другие части метаданных файла.
Журнал изменений содержит информацию о каждой ревизии. В каждую ревизию записывается, кто передал изменение, комментарий ревизии, другую связанную с этой ревизией информацию и манифест ревизии для дальнейшего использования
Внутри журнала изменений, манифеста или filelog'а каждая ревизия хранит указатель на своего непосредственного родителя (или на двух родителей, если эта ревизия получена при слиянии). Как я упоминал выше, есть также отношения между ревизиями через эти структуры, и они являются иерархическими по природе.
Каждому набору изменений в репозитории соответствует строго одна ревизия, хранящаяся в журнале изменений. Каждая ревизия в журнале изменений указывает на единственную ревизию в манифесте. Ревизия в манифесте указывает на единственную ревизию каждого filelog'а, отслеживающего, когда эта ревизия была создана. Эти взаимосвязи отражены на Рисунок 4.2, «Взаимосвязь метаданных».
Как показывает рисунок, между ревизиями в журнале изменений, манифесте и filelog'е нет отношений вида «один к одному». Если манифест не изменился между двумя наборами изменений, записи журнала изменений для этих наборов укажут на ту же самую ревизию манифеста. Если файл, который Mercurial отслеживает не изменился между двумя наборами, разделы для этого файла в двух ревизиях манифеста укажут на одну и ту же ревизию его filelog'а[3].
Взаимосвязи журналов изменений, манифестов и filelog'ов обеспечены единой структурой, названной журнал ревизий (revlog).
Журнал ревизий эффективно хранит ревизии используя дельта-механизм. Вместо хранения полной копии файла для каждой ревизии он содержит изменения необходимые для преобразования из старой ревизии в новую. Для многих типов файлов данных эти дельты составляют менее одного процента от размера полной копии файла.
Некоторые устаревшие системы контроля ревизий могут работать только с дельтами текстовых файлов. Они должны хранить бинарные файлы как полные копии или перекодировать их в текстовое представление, но оба этих подхода слишком расточительны. Mercurial эффективно обращается с дельтами файлов с произвольным бинарным содержимым; ему не нужно рассматривать текст как нечто особое.
Mercurial только дописывает данные в конец revlog-файла. Он никогда не изменяет секцию файла после того, как файл был записан. Это более здравый и эффективный подход чем схемы, которые должны изменить или переписать данные.
Кроме того, каждое обращение Mercurial'а пишется как часть транзакции, которая может охватить много файлов. Транзакция является атомарной: или вся транзакция проходит, и все ее эффекты сразу видны читателям, или же вся транзакция не происходит. Эта гарантия атомарности означает, что, если Вы управляете двумя копиями Mercurial'а, где один читает данные и один пишет их, читатель никогда не будет видеть частично записанный результат, который мог бы запутать его.
Тот факт что Mercurial оперирует только с файлами делает гарантированное выполнение транзакции более простым. Чем проще он выполняет подобные действия, тем вы можете более уверенными, что все выполнено корректно.
Mercurial грамотно обходит скользкое место, обычное для всех более ранних систем контроля версий — проблему неэффективного восстановления версий. Большинство систем контроля версий хранят содержимое ревизий как возрастающие серии модификаций по сравнению с «моментальным снимком». Чтобы восстановить определенную ревизию, вам необходимо прочитать моментальный снимок, а затем все ревизии между ним и интересующий вас. Чем более длительную историю имеет файл, тем больше ревизий вам придется прочитать, следовательно, больше времени уйдет на восстановление отдельной ревизии.
Нововведение в Mercurial решает эту проблему просто, но эффективно. Когда суммарное количество информации в дельте по сравнению с последним снимком превышает заданный порог, Mercurial сохраняет новый снимок (сжатый, конечно), вместо следующей дельты. Это позволяет быстро восстановить любую ревизию файла. Такой подход работает настолько хорошо, что уже был скопирован несколькими другими системами контроля версий.
Рисунок 4.3, «Моментальный снимок журнала изменений с возрастающими дельтами» иллюстрирует идею. В разделе журнала изменений индексированного файла Mercurial сохраняет список разделов из файла с данными, который необходимо прочитать для восстановления определенной ревизии.
Если вы знакомы со сжатием видео или когда-нибудь смотрели кабельное или спутниковое ТВ, то знаете, что в большинстве схем сжатия видео каждый кадр видео хранится как дельта от предыдущего кадра. Кроме того эти схемы используют сжатие с потерями для увеличения коэффициента сжатия, поэтому ошибки отображения накапливаются в зависимости от числа межкадровых дельт.
Mercurial использует эту идею для реконструкции ревизий из снапшотов и небольшого количества дельт.
Вместе с дельтами или снимками информации, журнал изменений содержит криптографический хеш представленных данных. Это затрудняет подделку содержимого ревизии и облегчает обнаружение случайной порчи данных.
Хеши нужны не только для защиты от искажений — они используются и как идентификаторы ревизий. Хеши идентифицирующие наборы изменений, которые Вы видите как конечный пользователь, это хеши от ревизий журнала изменений. Хотя filelog'и и манифесты также используют хеши, Mercurial задействует их только во внутренних процессах.
Mercurial проверяет правильность хешей при восстановлении ревизий файлов и при перемещении изменений из другого репозитория. При обнаружении проблемы целостности, он пожалуется и остановит текущие действия.
В дополнение к эффективности поиска, использование Mercurial’ом периодических снимков делает его более устойчивым против частичного нарушения целостности данных. Если журнал ревизий частично поврежден из-за аппаратной или системной ошибки, часто возможно восстановить некоторые или большинство ревизий от неповрежденных секций журнала ревизий и перед, и после поврежденной секции. Это не было бы возможно с моделью хранения только дельты.
Каждый раздел в журнале ревизий Mercurial точно соотносится со своей непосредственной ревизией-предком, обычно называемой родителем. Фактически, ревизия содержит место не для одного родителя, а для двух. Mercurial использует специальный хеш, называемый «нулевым идентификатором» для обозначения «нет здесь никакого родителя». Этот хеш — просто строка нулей.
На Рисунок 4.4, «Общий вид структуры журнала ревизий», вы можете видеть пример общего представления структуры журнала ревизий. Filelog`и, манифесты и журналы изменений имеют такую же структуру, они отличаются только видом данных, хранящихся в дельтах и снимках.
Первая ревизия в журнале (изображена внизу рисунка) имеет нулевые идентификаторы для обоих родителей. Для «нормальной» ревизии в слоте для одного родителя указан идентификатор ревизии-родителя, а второй слот содержит нулевой идентификатор, показывающий что у ревизии только один реальный родитель. Любые две ревизии с одинаковыми идентификаторами родителей называются ветвями. Ревизия, представляющая собой слияние между ветками имеет два нормальных идентификатора ревизий в родительских слотах.
В рабочем каталоге Mercurial хранит точную копию файлов из репозитория в соответствии с одной из ревизий.
Рабочий каталог «знает» какую из ревизий он содержит. Когда вы обновляете рабочий каталог до определенной ревизии, Mercurial просматривает соответствующую ревизию манифеста и находит там, какие файлы он отслеживал в то время, когда была создана ревизия, и какая из ревизий каждого из этих файлов была текущей. Потом он пересоздает копию каждого из этих файлов с содержимым на момент фиксации ревизии.
Dirstate это особая структура, которая содержит знания о рабочий каталоге Mercurial. Она содержится в файле с именем .hg/dirstate
внутри хранилища. Dirstate детализирует ревизию которая содержит в рабочем каталоге и все файлы, которые Mercurial отслеживает в рабочей директории. Он также позволяет Mercurial быстро сообщает об измененных файлах, записывая их время вытягивания и размеры.
Так же, как и у ревизии в revlog есть место для двух родителей (обычная ревизия — с одним родителем, и результат слияния двух ревизий — с двумя родителями), у dirstate тоже есть слоты для двух родителей. Когда вы выполняете команду hg update, ревизия, которую вы обновляете сохраняется в слоте «первого родителя», а во второй слот помещается нулевое значение. Когда вы выполняете hg merge с другой ревизией, первый родитель остается неизменным, а вторым родителем становится ревизия с которой вы осуществляете слияние. Команда hg parents показывает родителей текущего состояния каталога.
Dirstate хранит информацию о родительских ревизиях не только для своих целей. Mercurial сохраняет родительские ревизии состояния рабочего каталога как родителей новой ревизии во время коммита.
На Рисунок 4.5, «Рабочий каталог может иметь две родительские ревизии» показано нормальное состояние рабочего каталога, с одной ревизией в качестве родителя. Эта ревизия — окончание ветки (tip) — самая последняя ревизия в репозитории, у которой нет детей.
Полезно думать о рабочем каталоге, как о «ревизии, которую я сейчас зафиксирую». Любая операция с файлами (добавление, удаление, переименование, копирование), о которой вы сообщили Mercurial, будет отражена в этой ревизии, так же как и изменение в тех файлах, состояние которых он уже отслеживает. У новой ревизии будут те же родители, что у рабочего каталога.
После фиксации Mercurial обновит родителей рабочего каталога таким образом, что первым родителем будет идентификатор новой ревизии, а вторым — нулевой идентификатор. Это показано на Рисунок 4.6, «После коммита у рабочего каталога появляются новые родители» Mercurial не изменяет файлы в рабочем каталоге при фиксации изменений, он всего лишь изменяет dirstate, чтобы запомнить новых родителей.
Абсолютно нормально обновлять текущий каталог до ревизии, которая не является последней. Например, вы хотите знать, как выглядел ваш проект в прошлый вторник, или вы просматриваете ревизии, чтобы найти ту, в которой появилась ошибка. В подобных случаях обычное дело обновить рабочий каталог до интересующей ревизии и потом просматривать содержимое файлов в нём в том состоянии, в каком они были во время фиксации ревизии. Результат этих действий показан на Рисунок 4.7, «Рабочий каталог, обновленный до ранней ревизии».
Что произойдет, если мы сделаем изменения в то время, как рабочий каталог обновлен до более старой ревизии, а потом зафиксируем их? Mercurial поступит точно так, как я и говорил раньше. Родители рабочего каталога станут родителями новой ревизии. У новой ревизии не будет детей, так что она станет конечной ревизией. И с этого момента в репозитории будет две ревизии без детей, мы называем их головами (head). Вы можете посмотреть на то, что получилось на Рисунок 4.8, «После фиксации, сделанной в то время, как рабочий каталог был обновлен до ранней ревизии.».
Рисунок 4.8. После фиксации, сделанной в то время, как рабочий каталог был обновлен до ранней ревизии.
Когда вы выполняете команду hg merge, Mercurial оставляет первого родителя рабочего каталога неизменным, а вторым родителем назначает ревизию, с которой вы осуществляете слияние (см. Рисунок 4.9, «Слияние двух голов»)
Mercurial также изменяет рабочий каталог, чтобы осуществить слияние файлов из двух ревизий. Немного упрощенно процесс слияния проходит следующим образом — для каждого файла в манифестах обеих ревизий:
Если ни одна из ревизий не изменяла файл, то ничего с ним не делать
Если одна ревизия изменила файл, а другая — нет, то создать измененную копию файла в рабочем каталоге
Если одна ревизия удалила файл, а другая — нет (или тоже удалила его), то удалить файл из рабочего каталога
Если одна ревизия удалила файл, а другая изменила его, то спросить пользователя что делать — оставить измененный файл или удалить его?
Если обе ревизии изменили файл, то вызвать внешнюю программу для слияния, чтобы определить содержимое слитого файла. Эта операция может потребовать взаимодействия с пользователем.
Если одна ревизия изменила файл, а другая переименовала или скопировала файл, то удостовериться, что изменения будут перенесены в файл с новым именем
На самом деле все сложнее — у слияния есть множество подводных камней, но вышеперечисленное — это основные решения, которые нужно принимать при слиянии. Как видите, большая часть из них полностью автоматизирована, и таким образом, большая часть слияний завершается автоматически, без вмешательства пользователя для разрешения конфликтов.
Когда вы думаете о том, что произойдет, когда вы зафиксируете изменения после слияния, опять вспомните правило «рабочий каталог — это ревизия, которую я сейчас зафиксирую». После завершения команды hg merge у рабочего каталога будет два родителя, они же и станут родителями новой ревизии.
Mercurial позволяет выполнять несколько слияний, но вы должны фиксировать результаты каждого слияния после каждого слияния. Это необходимо потому, Mercurial отслеживает только два родителя для ревизии и рабочего каталога. Хотя было бы технически возможно объединить несколько ревизий сразу, Mercurial считает что проще этого избегать. С многоходовым слиянием, есть риск ввести пользователей в заблуждение, отвратительным урегулированием конфликтов, и страшный беспорядок при слияния будет расти.
Удивительно ряд систем контроля версий практически не обращают внимания на изменении имени файла с течением времени. Так, например, это было общее, что если файл переименован то с одной стороны слияния изменения появятся, с другой стороны пропадут.
Mercurial записывает метаданные, когда вы говорите ему выполнить переименование или копирование. Он использует эти данные во время слияния, чтобы правильно делать слияние. Например, если я переименую файл, а вы правите без переименования, когда мы объединяем наши работы файл будет переименован и ваш изменения будут внесены.
В предыдущих разделах, я попытался осветить некоторые из наиболее важных аспектов проектирования в Mercurial, чтобы показать, что он уделяет особое внимание надежности и производительности. Тем не менее, внимание к деталям, не останавливается на достигнутом. Есть целый ряд других аспектов построения Mercurial, которые мне лично кажутся интересными. Я подробно опишу некоторые из них, отдельно от «большого списка» пунктов выше, так что если вам интересно, вы можете получить более полное представление о наборе идей, которые используются в четко разработанной системе.
Когда нужно, Mercurial хранит и основные файлы и дельты в сжатой форме. Для этого он всегда пытается сжать файл или дельту, но хранит сжатую версию только в том случае, если она меньше оригинальной.
Это означает, что Mercurial «правильно» обрабатывает ситуацию, когда сохраняется файл, который уже сжат, например, zip
-архив или JPEG-изображение. Когда подобные файлы пережимаются второй раз, они обычно больше оригинала, так что Mercurial сохраняет оригинальный zip
или JPEG.
Дельты между ревизиями сжатых файлов тоже обычно больше, чем сами файлы, и тут Mercurial тоже поступает «правильно». Если он видит, что размер такой дельты превышает размер файла, то он сохраняет сам файл, что опять же дает экономию дискового пространства по сравнению хранением только дельты файлов.
При сохранении ревизий на диск, Mercurial использует алгоритм сжатия «deflate» (такой же, как и в популярном формате архивов zip
), который сочетает хорошую скорость и приличный уровень сжатия. Однако, при передаче данных по сети, Mercurial распаковывает сжатые данные.
Если при передаче данных используется протокол HTTP, Mercurial переупаковывает весь поток данных целиком, используя алгоритм сжатия, который дает более высокую степень сжатия (алгоритм Burrows-Wheeler из архиватора bzip2
). Комбинация из алгоритма и упаковки потока целиком (вместо сжатия каждой ревизии по отдельности) в значительной степени сокращает количество передаваемых данных, что в результате даёт лучшую производительность практически на любых видах сетевых подключений.
Если используется протокол ssh, то Mercurial не сжимает поток, потому что ssh делает это сам. Вы можете сказать, Mercurial, чтобы всегда использовать функцию сжатия в ssh редактированием файла .hgrc
в вашем домашнем каталоге следующим образом.
[ui] ssh = ssh -C
Добавление к файлам — ещё недостаточное условие, чтобы гарантировать, что читатель не увидит частичнозаписанные данные. Если Вы ещё раз посмотрите на Рисунок 4.2, «Взаимосвязь метаданных» ревизии в журнале изменений указывают на ревизии в манифесте, а ревизии в манифесте — на ревизии в filelog`ах. Эта иерархия является преднамеренной.
При записи транзакция начинается с записи данных filelog`а и манифеста, а в журнал изменений ничего не записывается до окончания работы с этими данными. Чтение же начинается с журнала изменений, потом читается данные манифеста, а потом — данные filelog`а.
С момента завершения записи в filelog и манифест и до записи в журнал изменений невозможно чтение ссылок на частично записанную ревизию манифеста из журнала изменений и на частичную ревизию filelog`а из манифеста.
Порядок чтения/записи и гарантии атомарности подразумевают, что Mercurial не нуждается в блокировке репозитория при чтении данных даже если паралельно с чтением происходит запись. Это оказывает большой эффект на масштабируемость; у Вас может быть произвольное число процессов Mercurial'а, безопасно читающих данные из репозитория одновременно, независимо от того записываются ли они него в или нет.
Неблокирующее чтение означает, что при использовании репозитория в многопользовательской системе вам не нужно наделять других локальных пользователей правами записи в ваш репозиторий для клонирования или вытягивания изменений из него; им будет достаточно прав чтения. (Это не общая черта среди систем контроля версий, так что не принимайте её, как очевидное! Большинство требует, чтобы читатели были в состоянии блокировать репозиторий для безопасного доступа к нему, а это требует прав записи по крайней мере для одного каталога, что, конечно, способствует всем видам противной и раздражающей безопасности и административным проблемам.)
Mercurial использует блокировки, чтобы гарантировать, что только один процесс может писать в репозиторий за один раз (механизм блокировки безопасен даже для файловых систем, которые известны своей враждебностью к блокировкам, такими как NFS). Если репозиторий будет блокирован, то программа записи будет ждать некоторое время, чтобы повторить попытку, если репозиторий будет разблокирован, но если репозиторий останется блокированным слишком долго, то процесс, пытающийся написать, будет остановлен по таймауту через некоторое время. Это означает, что Ваши ежедневные автоматизированные скрипты не будут застревать навсегда и накапливаться, если в системе незамеченный сбой, например. (Да, время ожидания настраивается, от нуля до бесконечности.)
Как и в случае с ревизией данных, Mercurial не блокирует файл dirstate для чтения; файл блокируются только для записи. Чтобы избежать чтения частично записанной копии файла dirstate, Mercurial пишет в файл с уникальным именем в том же каталоге, что dirstate, а затем автоматически переименовывает временный файл в dirstate
. Файл с именем dirstate
, таким образом, гарантированно будет полным, а не частично сохраненным.
Критичным для производительности Mercurial является предотвращение поиска сектора головкой диска, поскольку любой поиск требует гораздо больше накладных расходов, чем длительная операция чтения.
Вот почему, например, dirstate сохраняется в едином файле. Если бы этот файл сохранялся в каждом подкаталоге структуры, обрабатываемой Mercurial, операцию поиска пришлось бы делать по разу на каждый подкаталог. Вместо этого Mercurial читает единственный цельный файл dirstate за один шаг.
Mercurial также использует схему «копирование при записи», когда клонирует репозитарий в локальное хранилище. Вместо копирования каждого файла revlog из старого репозитария в новый создается «жесткая ссылка», которая является стенографическим способом сказать: «эти два имени указывают на один и тот же файл». Когда Mercurial готовится изменять один из файлов revlog, он проверяет, нет ли у этого файла нескольких имен. Если есть, — это значит, что больше одного репозитария используют данный файл, и Mercurial создает новую копию этого файла, приватную для данного репозитария.
Некоторые разработчики revision control отмечают, что эта идея по созданию полной частной копии файла не слишком эффективна с точки зрения использования свободного места. Хотя это и правда, хранение дешево, и этот метод дает наилучшую производительность вычислений на большинстве операционных систем. Альтернативные схемы, вероятнее всего, уменьшат производительность и увеличат сложность приложения, а обе эти характеристики весьма важны для «ощущений» от повседневного использования.
Поскольку Mercurial не требует от Вас сообщать ему, когда Вы изменили файл, он использует dirstate для сохранения некоторой дополнительной информации, помогающей ему эффективно определять, что файл изменен. Для каждого файла в рабочем каталоге сохранятся время последнего изменения, и размер файла в этот момент.
Когда вы явным образом производите операции hg add, hg remove, hg rename или hg copy с файлами, Mercurial обновляет dirstate и таким образом знает, что необходимо делать с этими файлами, когда Вы фиксируете изменения (commit).
Dirstate помогает Mercurial эффективно проверять статус файлов в репозитории.
Когда Mercurial проверяет состояние файлов в рабочем каталоге, в первую очередь сравнивается время изменения файла и время записанное Mercurial в файл dirstate при последней фиксации файла. Если время последней модификации и время записанное при последней фиксации совпадают, то значит файл не должен был изменится, и Mercurial не проверяет этот файл дальше.
Если размер файла изменился, файл должен был изменится. Если время модификации изменилось, а размер нет, только тогда Mercurial необходимо реально прочитать содержимое файла для того, чтоб посмотреть изменился ли он.
Сохранение времени модификации и размера поразительно уменьшает количество операций чтения, которые нужно совершать Mercurial при каждом запуске команд похожих на hg status. Такой результат приводит к значительному увеличению производительности.
[3] Возможно (хотя и необычно) манифест между двумя ревизиями останется неизменным, в этом случае запись лога изменений для ревизии будет указывать на ту же ревизию в манифесте.