В итоге пришел к такому решению:
Моя основная проблема - не как атомарно обновить 2 файла, а создавать снапшот приложения и очищать лог (чтобы размер не становился огромным) так, чтобы согласованность не нарушалась.
Моя проблема заключалась в изначально неправильном проектировании файловой структуры - было только 2 файла: снапшот и лог.
Спустя время нашел следующее решение:
- Файл снапшота 1 - для его обновления создается временный файл снапшота, который атомарно переименовывается (системный вызов rename атомарный - так прописано в документации)
- Вместо единого файла лога используется сегментированный лог - весь лог хранится в нескольких меньших файлах. Т.е. теперь я работаю с логическим файлом лога, который состоит из нескольких физических сегментов. Сегменты лога не удаляются при создании снапшота - этим может заниматься фоновый поток, который удаляет те сегменты все записи которых по индексу меньше индекса примененной записи в снапшоте.
Так как я использую RAFT, то необходимость в UNDO логе при записи в лог отпадает:
- запись append only, т.е. закоммиченные записи перезаписываться не будут
- лог может быть в несогласованном состоянии только если не до конца будут перезаписаны незакоммиченные записи, но это можно понять по чек-суммам, которые идут после каждой записи (можем откатить лог)
Также нашел недостаток в моей реализации - я храню индекс последней закоммиченной записи на диске (хранил в логе). Этого можно не делать и спокойно хранить в памяти, инициализируя на старте в 0 (ничего не закоммичено). Такой подход я также нашел в реализации рафт от HashiCorp (точнее есть
открытый issue). Этот индекс будет инициализирован либо существующим лидером, либо при старте он будет корректно выставлен согласно консенсусу.
В итоге работа со снапшотами будет примерно следующей:
- Добавляем (при необходимости перезаписываем) записи в лог
- Если размер сегмента превысил максимальный, то создаем новый и закрываем старый (работа будет вестить с новым файлом)
- Новый снапшот создаем во временном файле
- Когда снапшот готов, то атомарно вызовом rename перемещаем его в целевой файл (замена)
- Фоновый поток в один момент обнаружит, что есть файлы лога с неактуальными записями и тогда их (старые сегменты лога) удалит
Похожие идеи я нашел в:
- etcd - создание новых файлов сегментов через атомарный rename
- kafka - все записи хранит в xxxxxxx.log файле, где xxxxxxx означает офсет первой записи