Особенности файловых систем и их влияние на механизм синхронизации

Dragokas

Angry & Scary Developer
Команда форума
Супер-Модератор
Разработчик
Клуб переводчиков
Сообщения
7,814
Реакции
6,593
1.jpg



Одна из основных функций десктопного клиента Облака Mail.Ru — синхронизация данных. Ее целью является приведение папки на ПК и ее представления в Облаке к одинаковому состоянию. При разработке этого механизма мы встретились с некоторыми, с первого взгляда, достаточно очевидными особенностями различных файловых и операционных систем. Однако если о них не знать, можно столкнуться с довольно неприятными последствиями (не получится загрузить или удалить файл). В этой статье мы собрали особенности, знание которых позволит вам правильно работать с данными на дисках и, возможно, убережет от необходимости срочного хотфикса.
ReadDirectoryChangesW для Windows, FSEventStreamдля macOS и inotify для Linux. И уже тут подстерегают неприятные моменты. Дело в том, что под macOS нельзя с уверенностью сказать, какое именно событие пришло от файловой системы. Вы запросто можете получить CREATED, DELETED, RENAMED, MODIFIED на файл в одном событии. И, вроде бы, все логично: если есть удаление, значит файла уже нет, однако:
Bash:
$ rm 1.txt && echo "hello" > 1.txt
придет одним событием:
1.txt: CREATED | REMOVED | MODIFIED
Поэтому приходится использовать дополнительные механизмы проверки событий для понимания, что именно произошло с файлом или директорией.

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

2. С символическими ссылками не получится работать как с обычными файлами

Символические ссылки могут быть зацикленными: A -> B -> C -> B. Решить эту проблему можно, например, с помощью номера inode (уникальный номер файла или папки в текущем разделе диска, но о них чуть ниже). В нашем случае мы храним список inode символических ссылок, по которым прошли до текущей директории. Если inode текущей символической ссылки совпадает с тем, что уже есть в списке, то считаем ее зацикленной и пропускаем.

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

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

3. Имена файлов и папок могут быть в неправильной UTF-16

Был один интересный баг. В локальном дереве пользователя, который зарепортил нам проблему, был файл. Однако при попытке его чтения мы понимали, что файла нет. Вроде бы логичная ситуация, когда в момент нашей работы файл удаляется. Но при следующем листинге директории файл опять был на месте. Дело в том, что под Windows можно создать невалидную кодировку UTF-16. Точнее, название может содержать невалидную суррогатную пару. Конвертировать такое название в UTF-8, а затем обратно в UTF-16 стандартными средствами (WideCharToMultiByte, MultiByteToWideChar) не получится. Возьмем пример:
C++:
wchar_t name[] =  { 0xDCA9, 0x2E, 0x74, 0x78, 0x74, 0x00 };


2.png



Суррогатные пары состоят из High и Low значения и нужны для того, чтобы расширить диапазон кодируемых символов. High Surrogates лежат в диапазоне xD800 — xDB7F. Low Surrogates в диапазоне DC00 — DFFF. В нашем названии мы взяли High, но не взяли Low. Таким образом, мы получили невалидный UTF-16.

Конвертируем такое название в UTF-8, затем обратно:
C++:
wchar_t name2[] =  { 0xFFFD, 0x2E, 0x74, 0x78, 0x74, 0x00 }; // "�.txt"


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

Код примера
C++:
#include <assert.h>
#include <string>
#include <Windows.h>

std::string utf16ToUtf8(const std::wstring& utf16) {
    int size = WideCharToMultiByte(CP_UTF8, 0, utf16.data(), static_cast<int>(utf16.size()), NULL, 0, NULL, NULL);
    std::string utf8(size, 0x00);
    WideCharToMultiByte(CP_UTF8, 0, utf16.data(), static_cast<int>(utf16.size()), &utf8[0], size, NULL, NULL);
    return utf8;
}

std::wstring utf8ToUtf16(const std::string& utf8) {
    int size = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), NULL, 0);
    std::wstring utf16(size, 0x00);
    MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), &utf16[0], size);
    return utf16;
}

int main() {
    std::wstring original_utf16 = { 0xDCA9, 0x2E, 0x74, 0x78, 0x74, 0x00 };

    // Создаем файл с невалидной суррогатной парой
    HANDLE handle = CreateFileW(original_utf16.c_str(), GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
    if (handle == INVALID_HANDLE_VALUE) {
        return 1;
    }

    CloseHandle(handle);

    // Преобразуем название в UTF-8 и обратно
    std::string utf8 = utf16ToUtf8(original_utf16);
    std::wstring utf16 = utf8ToUtf16(utf8);

    // Снова пытаемся открыть файл с преобразованным названием
    handle = CreateFileW(utf16.c_str(), GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (handle == INVALID_HANDLE_VALUE) {
        // Не имеем доступа до файла
        assert(original_utf16 == utf16);
        return 1;
    }

    // Сюда никогда не придем
    CloseHandle(handle);
    return 0;
}

Мы в модуле синхронизации всегда работаем с UTF-8. Получаем от файловой системы события или листинг и преобразуем названия в UTF-8. Сервер также работает с UTF-8. При обращении к файловой системе, мы преобразуем UTF-8 обратно в UTF-16. Проблема была решена запретом синхронизации невалидных UTF-16.

4. Подводные камни при работе с inodes

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

Одно дело, когда от файловой системы приходит событие переименования. Тут проблем никаких. Тыц-тыц и переименовали. А если приложение выключено? Нужна какая-то информация, по которой мы будем детектировать событие переименования. Было несколько вариантов детектирования перемещения:

  • Сравнивать иерархию файлов и папок. Весьма тяжелый процесс, даже исходя из того, что деревья хранятся в оперативной памяти.
  • Создавать скрытые файлы со служебной информацией в каждой папке, по которым мы будем понимать, куда папка переместилась или во что переименовалась. Однако это вызывает некоторые сложности, включая то, что пользователь может менять и редактировать эти служебные файлы, что может привести к неприятным последствиям. Да и «следить» в каждой директории не хотелось.
  • Inodes. На этом варианте мы и остановились.

Inode — индексный дескриптор. Обозначается целым числом и представляет собой идентификатор файла или папки в конкретной файловой системе.

3.png



Чуток более человечное описание «как это работает» рекомендую почитать в этой статье. В POSIX получаем inode из stat (st_ino), в Windows — GetFileInformation (nFileIndex). И все, вроде бы, просто:

  1. Клиент перезапускается, подгружаем закэшированное представление файловой иерархии.
  2. Сравниваем с тем, что сейчас лежит на диске по факту.
  3. Находим узлы, номера inode которых отсутствуют в месте, где мы полагаем, но есть в каком-то другом месте.
  4. Перемещаем эти узлы.

Однако с inodes нужно быть очень и очень осторожными. Вот некоторые подводные камни, с которыми мы столкнулись.

4.1. Хардлинки

Каждая ссылка данного типа на один файл имеет одинаковый номер inode. Мы не детектим переименование, если в дереве есть хардлинки. Хардлинк нельзя создать на папку (ну или почти нельзя), потому особенных проблем тут нет.

4.2. Inodes могут работать иначе, чем вы ожидаете

На некоторых файловых системах номера inode присваиваются не так, как должны (ну или как нам кажется, что должны). Мы полагаем, что их номера при переименовании файла не изменяются. Также мы предполагаем, что если последний файл на ФС с inode 9 удалить, то следующий файл будет иметь inode номер 10. К сожалению, некоторые файловые системы с этим не согласны.

Под macOS на FAT создаются новые файлы (не папки) с inode номер 9999… При переименовании этих файлов номер inode не меняется. При редактировании этих файлов номера меняются на порядковые значения, которые мы и ожидаем увидеть:
Bash:
$ touch 1.txt
$ ls -i
999999999 1.txt
$ echo "hello" > 1.txt
$ ls -i
223 1.txt

Ext4. Дело в том, что если на этой файловой системе (которая является стандартной в большинстве дистрибутивов Linux), удалить файл с inode номер 9 в одном месте и создать новый файл в другом месте, он будет иметь inode с номером не 10 или выше, а 9.
Bash:
$ touch 1.txt
$ ls -i
270 1.txt
$ rm 1.txt && touch 2.txt
$ ls -i
270 2.txt

Т.е. на данной файловой системе номером inode становится первый свободный номер. Это немного сломало нам логику. Решение пришло само собой: если задетектили переименование папки, сравниваем для ее контента номера inode для папок и хэш + размер для файлов. Если директории совпадают на 70% и выше — переименовываем. Для файлов — если хэш + размер совпали.

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

5. Программы хранят много служебных файлов на диске

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

Windows:
  • desktop.ini — хранит пользовательские настройки для текущей директории;
  • Thumbs.db — кэши эскизов для изображений;
  • файлы, начинающиеся с "~$", или ".~", или начинающиеся с "~" и заканчивающиеся ".tmp" — довольно распространенный шаблон временных файлов. Файлы такого шаблона также создает Microsoft Office при редактировании документов.

macOS:
  • .DS_Store — аналог desktop.ini под Windows;
  • Icon\r — достаточно интересный файл, при листинге файл отображается как «Icon?», хранит информацию о изображении на директории, в которой находится;
  • файлы, начинающиеся с "._" — достаточно много было шаблонов вместо этого, однако, разнообразным ПО больше нравится использовать свой формат временных файлов, после чего и было решено игнорировать файлы по данной маске.

Linux:
  • .directory — аналог desktop.ini под Windows и .DS_Store под macOS, актуально для некоторых оконных менеджеров.

6. Особенности путей в Windows до файлов и папок

Пути под Windows, безусловно, заслуживают отдельного внимания. Для путей, превышающих значение MAX_PATH (260 символов),нужно использовать перефикс "\\?\". Данный префикс, кстати, нужно использовать для CreateFile, если вы собираетесь открыть COM-порт.

Windows для каждого файла или папки, название которых длиннее 8 символов, создает короткие альясы (еще называются «8.3»). Альясы всегда в высоком регистре, содержат знак "~", за которым идет цифра, увеличивающаяся, если такой альяс уже занят (Например: «C:\PROGRA~1\»). Содержание этих признаков необходимо, но не достаточно, чтобы понять — перед вами обычное название или короткий альяс. WinApi умеет превращать короткие пути обратно в длинные (GetFullPathName). Однако нужно помнить, что он не превратит путь в длинное представление, если такой файл уже не существует.

Если кто-то откроет файл с помощью CreateFile, используя короткий путь и модифицирует его, то в событии от файловой системы (с помощью ReadDirectoryChangesW) вам придет такой же короткий путь. В связи с этим мы стараемся превратить их в длинные как можно скорее. Кстати, вы можете увидеть альясы, если введете «dir /x» из нужной директории в командной строке Windows.

4.png


Еще одной неприятной особенностью, которую нельзя пропустить: файлы и папки с точкой в конце нельзя открыть с помощью проводника (справедливо для Windows 7):

5.png


7. Заключение

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

Если у вас есть вопросы или замечания, смело задавайте их в комментариях или пишите лично мне на a.skogorev@corp.mail.ru.

Источник.
 
Назад
Сверху Снизу