Seed-driven тест на отказа питания (src/test/test_blockstore_chaos.cpp): настоящее хранилище —
журнал, метаданные, flusher, compaction, контрольные суммы, recovery — работает на заглушке диска,
полностью в RAM. Ничего над ним нет: ни OSD, ни PG, ни сети, ни etcd. Тест обращается к хранилищу
напрямую и проверяет только то, что обещает само хранилище. Это пара к
хаос-тесту PG, который считает хранилище корректным.
Всё, что НИЖЕ хранилища, симулируется: ответы io_uring приходят не в порядке отправки и со случайной задержкой, волатильный кэш записи удерживает часть записей, а потеря питания делает так, что бывшие в полёте записи сохраняются целиком, рвутся по границе сектора или не сохраняются вовсе, случайная половина кэша исчезает. Рядом ведётся модель того, что было записано, и после каждой имитации отказа восстановленное состояние сверяется с ней.
Один и тот же набор нагрузок и проверок прогоняется на обеих реализациях — v1 (meta_format 2)
и v2 (heap, meta_format 3), которые обязаны вести себя одинаково. Сид также задаёт
конфигурацию, поэтому упавший прогон воспроизводится тем же сидом; опции — по -h.
Данные: четыре объекта по 128 К (inode 1, четыре stripe) на одном mock-диске в 256 МБ, где метаданные лежат с нуля, журнал — с 16 М, данные — с 32 М; гранула записи 4 К.
Конфигурации (24)
- blockstore v1 / v2 (meta_format 2 / 3) ×
- immediate_commit none/all ×
- csum_block нет / 4096 / 16384 — 16384 намеренно больше 4 К гранулы записи, то есть запись покрывает лишь часть чексуммного блока ×
- мгновенная/двухфазная запись, как в репликах/EC (BS_OP_WRITE_STABLE / BS_OP_WRITE+BS_OP_STABLE)
2 × 2 × 3 × 2 = 24; конфигурация = seed % 24, переопределяется через --config. Смешать два
стиля записей в одном объекте нельзя: stable-версия поверх unstable отклоняется с EINVAL, а пул —
всегда что-то одно.
Модель мира
Для каждого объекта хранятся submitted (максимальная отправленная последовательность), acked,
synced, durable, а также отображения номер-последовательности → полное содержимое 128 К
(пустой вектор = «удалён»), → номер версии, который видит хранилище, и → байт битмапа.
Номер последовательности только растёт и именно он является ключом модели, потому что после
удаления версии снова начинаются с 1 — хранилище явно разрешает писать низкие версии поверх
удаления. deleted_upto не даёт старому снимку sync сделать stabilizable стёртую версию, а
can_rollback помечает, что именно этот экземпляр действительно стабилизировал durable-версию
(recovery мог слить её в одну чистую запись).
Зафиксированность выводится только из завершения операций, дополнительных флагов нет:
- при
immediate_commit=allзавершённая запись сразу считается synced; при волатильном кэше диска — ничто не synced, пока не завершится накрывающий её SYNC; - synced-удаление и synced-запись
BS_OP_WRITE_STABLEстабильны сами по себе и сразу становятся durable; двухфазную запись надо ещё стабилизировать, и это происходит асинхронно, позже, из другого шага.
Содержимое объектов самоидентифицируется: 32-байтные записи {seq, версия, объект, смещение},
размер которых делит гранулу 4 К, поэтому такая запись никогда не пересекает границу операции
записи или чтения, и любое расхождение декодируется в «какая версия записала эти байты».
Внешние битмапы тоже самоидентифицируются: каждая запись заполняет свой 4-байтный битмап (один бит на 4 К гранулу) одним узнаваемым байтом. Для хранилища битмап непрозрачен, но он путешествует вместе с записью через compaction, recovery и откаты так же, как данные.
Инвариант: за раз с объектом работает только одна операция. Пока в полёте его запись, чтение, stabilize или rollback, ничто другое к нему не подходит — поэтому базовое содержимое записи и ожидаемый ответ чтения не могут измениться под ними.
Симулируемая реальность
- Цикл событий: один шаг — это завершение самого раннего запроса диска, и виртуальные часы прыгают прямо к нему, поэтому заданные задержки не замедляют тест. Один seed-driven PRNG задаёт и задержки, и нагрузку, и броски «костей» при отказе питания.
- Симулятор диска: задержка на каждый запрос берётся равномерно (10–200 мкс на чтение и
запись, 100–2000 мкс на fsync) — именно из-за разброса completion’ы приходят не по порядку;
сектор 4 К; записи до 4 К атомарны и ложатся на диск целиком или не ложатся вовсе. В буферном
режиме (
immediate_commit=none) записи попадают в волатильный кэш и доходят до носителя только по fsync. Приpower_loss()каждая запись в полёте сохраняется целиком, рвётся по границе сектора или не сохранятся вовсе, а записи в кэше выживают и сохраняются на диск с вероятностью 50%, после чего кэш очищается, т.к. при следующем запуске он должен быть пустым и не исчезать при ещё одном отказе. Инъекция ошибок read/write/fsync существует, но по умолчанию выключена: хранилище по замыслу считает большинство ошибок записи фатальными. - Заглушка ringloop: неизвестный fd немедленно обрывает тест;
reset()выбрасывает очереди и ожидающие completion’ы, как если бы процесс умер, — ни один «просроченный» колбэк не может прилететь в уничтоженное хранилище. Отказ питания по определению очищает очередь in-flight диска, а чистая перезагрузка дожидается простоя диска явно.
Операции (одна на шаг)
На каждом шаге действие выбирается по весу (--p-*; 0 выключает его):
- wait (500) — ничего не слать, прокрутить одно событие симуляции.
- write (60) — запись случайного выровненного по 4 К диапазона случайного свободного объекта: новая версия (нумерация после удаления начинается заново), самоидентифицируемое содержимое, свой битмап.
- delete (6) — удаление существующего объекта; удаление несуществующего — no-op, на который хранилище отвечает без создания записи, поэтому он не тестируется. Удаление стабильно само по себе.
- read (40) — чтение случайного диапазона: выровненного по 4 К, но — назло — не выровненного по
размеру чексуммного блока, чтобы чтения резали и чексуммные блоки, и границы реально записанных
экстентов; либо целиком с вероятностью
full_read% (25). Ответ проверяется по модели при завершении. - sync (30) — сброс кэша; покрытие — множество подтверждённых версий на момент отправки, оно
трактуется как нижняя граница. Не отправляется, пока в полёте rollback: rollback откатывает
модель теста —
ackedиsyncedвозвращаются к стабильной версии, и всё выше неё из модели исчезает, — поэтому снимок покрытия начал бы ссылаться на версии, которых уже нет. Это ограничение модели теста, а не API хранилища. - stabilize (50) — зафиксировать случайный непустой префикс всего ставшего stabilizable одной операцией с несколькими парами (объект, версия) — ровно так, как делает OSD; одноэлементные операции при этом возникают так же естественно.
- rollback (6) — выбросить нестабильные версии вместо фиксации. Только двухфазный режим (мгновенная запись стабильна сразу) и только для объектов со стабильной неудалённой версией ниже последней. После успеха всё выше стабильной версии исчезает, и счётчик версий объекта в хранилище откатывается к ней. Не отправляется, пока в полёте sync (по той же причине) и пока в полёте другой rollback.
- restart (1) — ЧИСТАЯ перезагрузка: дождаться завершения всего в полёте, прочитать все объекты, дождаться простоя диска, уничтожить хранилище, создать заново, прочитать снова.
- crash (2) — выдернуть шнур прямо сейчас: потеря питания, всё бывшее в полёте учитывается как легальный, но неподтверждённый исход, хранилище уничтожается и создаётся заново, каждый объект проверяется по правилу восстановления.
Одновременно выполняется не более max_inflight (8) операций; шаг при полной очереди вместо
отправки ждёт завершения одной из них — здесь проверяется зависание.
В конце работы всё оставшееся в полёте дожидается, после чего каждый объект читается целиком и проверяется.
Учёт результатов операций (account)
- Завершилась с ожидаемым кодом возврата — содержимое записано в модель под своей
последовательностью,
ackedсдвинут, а дальнейшее (synced / durable / требует stabilize) выводится из конфигурации. - Запись или удаление с
-EAGAIN— отказ, а не потеря (журнал заполнен, flusher не успевает под нагрузкой); модель не меняется. - Упавший sync — значит, он ничего и не синхронизировал; это тоже легально под нагрузкой.
- Чтение, прерванное отказом питания, ничего не сообщает и просто отбрасывается.
- Всё остальное, включая упавшие stabilize/rollback, — падение теста с печатью затронутых пар (объект, версия).
- Запись, бывшая в полёте в момент отказа: её содержимое так же записывается в модель как легальный
исход, но
ackedне сдвигается, то есть она всё ещё может быть потеряна.
Проверки (все точки отмены)
Согласованность данных:
- чтение удалённого или никогда не писанного объекта обязано вернуть
-ENOENT; - чтение обязано вернуть ровно подтверждённое содержимое:
retval == lenи побайтовое совпадение по всему диапазону; первая расходящаяся 32-байтная запись печатается с obj/seq/version/offset и ожидаемым значением — то есть устаревшие данные сами называют своего автора; - внешний битмап в ответе обязан принадлежать именно возвращённой версии («returned the bitmap of another version»);
- после отказа питания или перезапуска чтение отвечает либо целым объектом, либо
-ENOENT— любой иной retval означает, что на диск попало порванное содержимое; - то, что объект «вернулся», обязано быть содержимым версии не ниже гарантированной границы: последней durable — после отказа питания, последней подтверждённой (последней synced при волатильном кэше) — после перезапуска, который ничего не потерял. Иначе — падение с «came back … holding unexpected content» и диффом по каждой версии-кандидату: та, что почти совпадает, обычно и показывает, какая запись потерялась или применилась не в том порядке;
- объект вправе полностью исчезнуть только тогда, когда ничего не гарантировалось; тогда модель сбрасывается, и нумерация начинается с 1;
- при
immediate_commit=allчистая перезагрузка обязана вернуть ровно то же состояние — тот же retval, та же версия, те же байты; в сообщении о падении печатается ещё и первый никогда не писанный сектор журнала, который прямо указывает на потерянную запись; - вернувшееся содержимое становится новой базой, но не новой durable-точкой: durable-содержимое
остаётся легальным исходом,
can_rollbackсбрасывается, и следующий отказ всё ещё может откатить объект к durable-версии; - rollback вправе удалить только версии выше стабильной, и счётчик версий откатывается соответственно — иначе это заметят последующие записи и чтения;
- финальные чтения целых объектов обязаны совпасть с подтверждённым содержимым.
Живость:
- «operations did not complete in 1000000 steps — deadlock?» — зависшие операции печатаются вместе с диагностикой хранилища (completed/fsynced/compacted LSN, очередь compact) в v2;
- «blockstore failed to start»;
- «a read of object N did not complete in MAX_STEPS — deadlock?» и «the final read of object N did not complete»;
- «disk did not go idle before a clean restart» — защита от use-after-free: ни один запрос не может пережить хранилище, которое его послало.
Учёт отказов:
- отказом записи/удаления принимается только
-EAGAIN, упавший sync трактуется как «ничего не синхронизировал», а любая другая ошибка — включая stabilize/rollback — обрывает тест.
Защиты тестовых заглушек:
- неожиданный fd в заглушке ringloop, отклонённый SQE,
--max-inflight < 1; - перекрывающиеся одновременные записи: запись, отправленная, пока в полёте находится другая, накрывающая часть её диапазона, — падение теста. Устройство вправе применить такую пару в любом порядке, поэтому полагаться на то, что победит более новая, — ошибка; ловить это на отправке детерминированно лучше, чем ждать неудачного порядка завершения.
Отчёт и воспроизводимость
По умолчанию тест прогоняет 48 фиксированных сидов (каждая конфигурация дважды) плюс 152 случайных,
равномерно разложенных по конфигурациям, — всего 200 сидов × 10000 шагов. Каждое сид выполняется в
отдельном дочернем процессе, поэтому одно падение не роняет весь прогон, а упавшие сиды печатаются
в конце. --seed N выполняет одно сид в том же процессе — именно это нужно под отладчиком;
--seeds a-b, --random n, --steps n, --config n и веса --p-* настраивают прогон
(например, --p-delete 0 --p-rollback 0 даёт нагрузку без удалений и откатов).
OK-строка: writes (refused with EAGAIN) / deletes / syncs / stabilizes / rollbacks / crashes / clean restarts / verified reads / disk ops.
Детерминизм: один сид ⇒ та же конфигурация и то же расписание; -v даёт трассу каждой операции и
каждого запроса к диску, включая смоделированную задержку, решение при отказе питания по каждой
бывшей в полёте записи и каждую запись, выброшенную из волатильного кэша.
Собирается через make build_tests, запускается ctest’ом как test_blockstore_chaos.