Замешиваем файлы в тэги. Часть 4

17e23eac44b1dbbf1acb4f64dc9eb11a.png

Продолжаем создавать модуль ядра в Линукс на примере виртуальной файловой системы.

Часть 1: Описание задачи, Модуль ядра

Часть 3: Inode, Lookup

Часть 4: Inode-операции: symlink, unlink

Что в результате получилось можно увидеть по ссылкам: демо-видео, код.

Продолжаем разбираться с операциями для inode:

symlink

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

  ino = tagfs_add_new_file(stor, name, de->d_name);
  if (ino == kNotFoundIno) { return -EEXIST; }
  newnode = tagfs_create_inode(inod->i_sb, S_IFLNK | 0777, ino + kFSRealFilesStartIno);
  if (!newnode) {
    return -ENOMEM;
  }
  tagfs_set_linkfile_operations_for_inode(newnode);
  d_instantiate(de, newnode);

  return 0;

Что делает код: добавляем новую запись в наше хранилище (tagfs_add_new_file (…)) и в результате получаем внутренний индекс файла. Далее на основе индекса создаём inode (tagfs_create_inode (…)), который является символьной ссылкой (маска S_IFLNK) и прав у неё по полной. В качестве номера ino для линукса используем полученный внутренний индекс с добавлением смещения kFSRealFilesStartIno. Зачем нужно смещение: во-первых номер ноды плохо сочетается с числом 0; во-вторых, задавая смещение мы можем зарезервировать ряд номеров для постоянных сущностей: у нас это директории only-files, only-tags и т.д.

Чем обеспечивается простота реализации?: Симлинка создаётся только если такого имени нет и линукс сам это предварительно проверяет (например, через вызов lookup — см. предыдущую статью). Наш код, конечно, тоже делает проверки.

Если же рассматривать случай создания симлинка в тегах, то тут немного сложнее: файл-симлинк может существовать в файловой системе (и его не нужно создавать ещё раз), но конкретно в данной директории он не показывается, потому что (например) не выставлены нужные тэги. Поэтому здесь явно проверяется наличие файла/тэга с новосоздаваемым именем, проверяется совпадение с именами тэгов, в том числе и с именами с no-префиксом. Если имени такого нет, то происходит создание симлинки и ей тут же насыпаются теги из директории: т.е. если симлинка создавалась в тегах no-комедия и детектив, то в созданной симлинке сразу же прописывается тег детектив.

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

Если создаётся симлинк с существующим именем и целевой файл совпадают, то (очевидно) новый файл не создаётся и вместо этого у существующей сущности меняются биты маски: файл приобретает новые тэги. Или удаляет старые, если мы, например, копируем файл в директорию …\no-боевик\no-фантастика, то теги боевик и фантастика удаляются с этой симлинки.

В коде это делается примерно так:

  tagmask_or_mask(mask, dir_info->on_mask);
  tagmask_exclude_mask(mask, dir_info->off_mask);
  res = tagfs_set_file_mask(stor, ino, mask);

Для текущей маски тэгов добавляются «включенные» биты и удаляются «выключенные» биты из маски директории. Чтобы работал такой механизм нода каждой директории содержит свою маску, признак no-префикса, наборы масок (включенные и выключенные). При перемещении к дочерним директориям производится их дополнение соответствующими битами: если мы проваливаемся в директорию фантастика, то дочерняя директория получает выставленный в on_mask дополнительный бит, соответствующий этому тэгу. Если проваливаемся в no-фантастика, то соответствующий бит ставится в маске off_mask. Подробнее это будет обсуждаться при описании работы с директориями.

После всех операций по созданию симлинки и/или тасовки битов для новосозданного inode задаются операции и эта нода привязывается к dentry. Здесь привязка к dentry делается через вызов функции d_instantiate и особенность её в том, что созданная нода не попадает в глобальную хэш-таблицу нодов, т.е. время жизни такой ноды будет небольшим. В штатной работе линукса вызовет lookup на созданное имя и получит хорошую, надёжную, захешированную ноду. Поэтому скорее всего назначение операций (например, на удаление или др.) для таких «временных» нод может быть излишним телодвижением, однако всё таки желательно возвращать работоспособную ноду — вдруг линукс в какой-нибудь цепочке вызовов будет всё это использовать (если не сейчас, так позже).

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

unlink

Операция unlink отвечает за удаление файла/тэга. В обычной файловой системе у каждого файла может быть несколько жёстких ссылок. Соответственно, обработчик unlink сначала снижается счётчик ссылок и когда счётчик добирается до нуля, то происходит реальное удаление файла из файловой системы с подчисткой записей о файле. В любом случае обработчик возвращает 0 в случае успешной операции по обновлению счётчика ссылок или удалению; или отрицательный код ошибки если что-то пошло не так.

В нашем случае вся эта логика не работает: файл-симлинка появляется в большом количестве директорий. Учитывать для каждой директории жёсткую ссылку на файл смысла нет. Удаление файла из директории приводит к появлению его в другой директории (см. Удаление — Козни 2 ниже). И вишенка на торте: файл должен удаляться из единственной директории. В общем, всё наперекосяк.

Прим.: вообще операция удаления для нестандартных (не-иерархических) файловых систем — это явные козни Шайтана и других недружелюбных личностей. Кратенько пройдёмся по этим козням:

Удаление — Козни 1:

В файловой системе есть места, где что-то удалять нельзя: например, в дереве тэгов нельзя удалять сами тэги.

то есть удалять-то вы, конечно, можете (кто же вам запретит?!), только

по логике использования тэговой файловой системы пользователи скорее предпочтут удаление файлов-симлинков из директории, т.к. удаление самого тэга повлияет даже на те файлы, которые в этой директории не отображаются. И естественно, такая реализация вызовет слёзы неподдельной благодарности от пользователей. Кроме того, в тэге скорее всего будут вложены другие тэги и тут можно сразу переходить к разделу Удаление — Козни 3.

И для того, чтобы тэги как-то можно было удалять требуется выделять отдельное место — например, директорию only-tags — и там позволяется удалять тэги. Причём если вы здесь попытаетесь сыграть с линуксом по его правилам (например, выставить права и запретить запись) — то это выйдет боком, так как возникнут проблемы с копированием в тэг файлов.

С другой стороны, реализация защиты простая: смотрим, когда ino для удаляемой сущности не попадает в разрешённый диапазон — возвращаем ошибку -EPERM.

  if (fi->i_ino < kFSRealFilesStartIno || fi->i_ino > kFSRealFilesFinishIno) {
    // Удалять в дереве тэгов можно только файлы. Остально - запрещено.
    return -EPERM;
  }

Удаление — Козни 2:

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

  // Модифицируем маску файла: удаляем/добавляем соответствующий тэг
  mask_bit = tagmask_check_tag(mask, dir_info->tag_ino);
  if (dir_info->on_tag) {
    if (mask_bit) { tagmask_set_tag(mask, dir_info->tag_ino, false); }
    else { res = -EINVAL; }
  } else {
    if (!mask_bit) { tagmask_set_tag(mask, dir_info->tag_ino, true); }
    else { res = -EINVAL; }
  }
  res = tagfs_set_file_mask(stor, fileino, mask);

А в остальном также потребуется отдельная директория для удаления файлов (у нас это only-files).

Удаление — Козни 3

Веселье начинается при рекурсивном удалении директории-тэга вместе с содержимым: и так как это обычное поведение в файловых менеджерах (midnight commander или др.), то будьте готовы. Кнопку Удалить в midnight commander вы, конечно, нажмёте. Только, например, при наличии 100 тэгов у вас вложенность директорий будет те самые 100. Сто, Карл! Т.е. в директории tags будет 100 поддиректорий с тэгами и 100 поддиректорий с no-тэгами. В каждой из таких поддиректорий будет 99 поддиректорий с тэгами и 99 поддиректорий с no-тэгами и так далее.

На примере 3-х тэгов это выглядит так:

tags:
. tag1
. . tag2
. . . tag3
. . . no-tag3
. . tag3
. . . tag2
. . . no-tag2
. . no-tag2
. . . tag3
. . . no-tag3
. . no-tag3
. . . tag2
. . . no-tag2
. tag2
. . tag1
. . . tag3
. . . no-tag3
. . tag3
. . . tag1
. . . no-tag1
. . no-tag1
. . . tag3
. . . no-tag3
. . no-tag3
. . . tag1
. . . no-tag1
. tag3
. . tag1
. . . tag2
. . . no-tag2
. . tag2
. . . tag1
. . . no-tag1
. . no-tag1
. . . tag2
. . . no-tag2
. . no-tag2
. . . tag1
. . . no-tag1
. no-tag1
. . tag2
. . . tag3
. . . no-tag3
. . tag3
. . . tag2
. . . no-tag2
. . no-tag2
. . . tag3
. . . no-tag3
. . no-tag3
. . . tag2
. . . no-tag2
. no-tag2
. . tag1
. . . tag3
. . . no-tag3
. . tag3
. . . tag1
. . . no-tag1
. . no-tag1
. . . tag3
. . . no-tag3
. . no-tag3
. . . tag1
. . . no-tag1
. no-tag3
. . tag1
. . . tag2
. . . no-tag2
. . tag2
. . . tag1
. . . no-tag1
. . no-tag1
. . . tag2
. . . no-tag2
. . no-tag2
. . . tag1
. . . no-tag1

И в каждой директории будет ещё файлы-файл-файлы. Даже просто пробежаться по всем директориям — это уже тяжело. Спасают ситуацию несколько моментов. Во-первых, когда начнётся удаление, то оно рискует быстро завершиться на первом попавшемся тэге (тэги удалять нельзя — см. Удаление — Козни 1). Правда есть заковырка: если пользователь — человек упорный и удаление будет с пропуском (скипом) ошибок, то возможно появление ощущения лёгкой грусти от количества директорий. Во-вторых, сами файлы всё равно не удалятся и для них поменяются маски тэгов (см. Удаление — Козни 2). Хотя и здесь есть заковырка: если условный файл удалится, например, из директории боевик (и он попадёт в директорию no-боевик), а потом удалится из директории no-боевик (и он вернётся обратно в директорию боевик), то всё даже ничего. Однако во второй раз файл может быть и не удалён: например, директория no-боевик была обработана раньше; или файловый менеджер не удаляет (не учитывает) файлы, появившееся в процессе удаления. В-третьих: при переборе дерева (например, посмотреть размер директорий или других подобных операциях) все эти проблемы тоже вылезают.

Как нормальной работать с этой ситуацией — не понятно. Пробовал выстраивать директории в «правильном» порядке при перечислении файлов и тогда на midnight commander сначала обрабатывались директории (условно) боевик, потом no-боевик. Однако как-то это всё шатко. Ещё пробовал вариант с частичным запрещением удаления симлинков из тэгов: из обычного тэга удалять можно, из no-тэга удалять нельзя. И чтобы файл из no-боевик появился в директории боевик необходимо не удалять файл, а копировать. Этакий лёгкий выверт. Но подход мне тоже не понравился. С другой стороны, это всё не фатально. Если у вас тэгов мало — то всё произойдёт быстро: файлы перетасуются, но не удалятся. Если же тэгов много, то вся операция будет долгой и вы её всё равно отмените.

Удаление — Козни 4

В обычной файловой системе если у вас есть файл file1 в цепочке директорий (например, tag1/tag2/tag3/file1), то удаление промежуточной директории tag2 приводит к удалению tag3 и file1. В нашей же файловой системе удаление одного тэга не влияет на другие тэги и не влияет на существование файлов. Т.е. путь tag1/tag2/tag3/file1 будет нормально существовать, все закэшированные dentry выдадут необходимую информацию и атрибуты; файл file1 будет открываться и всё будет хорошо, пока не подчистится кэш с этими данными.

Что тут делать? Да ничего не делать! Файл реальный, он существует. То что путь существует наполовину — с этим можно жить. Ведь если где-то в условном скрипте сохранился некоторый путь до файла для дальнейшего использования и вдруг этот путь исчез — это и в обычной файловой системе может произойти. В общем: кривенько, но жизнеспособно.

Удаление — Козни 5

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

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

Удаление — Козни 6

Помимо проблем, которые привносит наш код и наша файловая система со своими странными иерархиями, есть ещё 5 копеек от самого линукса. И имя этим 5 копейкам: кэш негативных dentry. Это фича, которую внёс сам линукс и он сам ей управляет. Мы можем только слегка подталкивать линукс в правильном направлении.

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

Однако в нашем случае что получается?: если был файл file1 в тэге «боевик» и его оттуда удалили, то он появится в папке no-боевик. Если же потом файл удалить из директории no-боевик, то он должен появится в папке боевик. И если мы провернём такое двойное удаление, то обнаружим, что файл file1 в директории боевик не появился: другие файлы есть, а file1 — нет; и в директории no-боевик его тоже нет. А во всём виноват кэш негативных dentry: линукс запоминает dentry на файл file1 в директории боевик и когда файл удаляют то он запоминает, что dentry негативный (всё, удалили файл), и больше про него ничего не спрашивает. Т.е. может дойти до смешного: линукс может запросить содержимое директории; наш код выдаст линуксу список, в котором несколько файлов и file1 среди них; линукс по каждому из файлов запросит информацию (lookup — inode и т.д.), кроме file1 — линукс его просто не заметит. Наша ФС готова рассказать линуксу всё про этот файл, только линукс не спрашивает. Упс!

По-идее, линукс должен как-то очищать этот кэш. Однако, для внешнего модуля ядра очистка этого кэша не предусмотрена, а очистку по устареванию элементов можно ждать очень долго.

Поэтому переходим к работе с «необычными» случаями. И тут нам снова помогают ребята из линукса: они ещё немного почесали репу и вспомнили, что есть случаи, когда кэш негативных dentry вреден.

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

Во-вторых, это сборки линукса со специфичными настройками. Например, есть файловые системы ext4 и в них (обычно) всё нормально. Однако, если собрать ядро с поддержкой нестандартного кодирования букв и регистро-независимости (orig: VFS negative dentries are incompatible with Encoding and Case-insensitiveness), то кэш тоже начинает мешать (в общем, линукс — он многоликий).

И для таких случаев предлагается пара обходных путей, которые выясняются через ползание по коду: инвалидация и ревалидация.

Инвалидация: состоит в том, что если у нас удаляется файл, то вместо получения негативного dentry мы делаем его «невалидным» — это такое пожелание линуксу выбросить этот dentry куда подальше. Забить на него: возможно есть inode для этого dentry, а возможно и нет. В общем, когда в следующий раз понадобится — лучше переспроси. Механика инвалидирования реализуется через вызов d_invalidate для требуемой dentry:

int tagfs_tag_dir_unlink(struct inode* dir, struct dentry* de) {
...
  // Workaround of negative dentry cache: invalidate dentry
  d_invalidate(de);

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

struct dentry* tagfs_tag_dir_lookup(struct inode* dir, struct dentry *de,
    unsigned int flags) {
...
  if (!mask_suitable || fileino == kNotFoundIno) {
    // Workaround of negative dentry cache: invalidate dentry
//     d_add(de, NULL);
    return NULL;
  }

Ревалидация: состоит в том, что если удаляется файл, то для его dentry добавляется операция d_revalidate. Линукс, в свою очередь, когда начинает думать запрашивать ли информацию об этой сущности или нет, то он вызывает эту операцию и смотрит на результат: если она возвращает положительное число, то линукс доверяет своему кэшу. Если возвращается 0 или отрицательное значение, то линукс явно переспрашивает информацию о файловой сущности. В коде это выглядит так:

int tagfs_tag_dir_revalidate(struct dentry* de, unsigned int flags) {
  return 0;
}


const struct dentry_operations tagfs_tag_dir_negative_dentry_ops = {
  .d_revalidate = tagfs_tag_dir_revalidate
};

// ... в обработчике unlink
  d_set_d_op(de, &tagfs_tag_dir_negative_dentry_ops);

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

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

Если рассматривать оба способа обхода кэша негативных dentry, то можно отметить, что они тормозят работу файловой системы. Причём сейчас (пока) в ФС обрабатываются только удалённые или несуществующие файлы. В будущем это может распространиться и на уже существующие файлы. Поэтому можно заложиться на будущую оптимизацию. И здесь подход ревалидации выглядит более правильным: идёт вызов нашей функции, которую можно научить, например, отслеживать время последней модификации или удаления и после этого для каждой dentry разрешать только одну ревалидацию. В общем, в ревалидации возможностей больше.

Прим.: подробнее про кэш негативных dentry можно почитать здесь [1]

Резюмируя функцию Удаления

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

[1] Dealing with negative dentry

Заставка является изображением, сгенерированным нейросетью Kandinsky.

© Habrahabr.ru