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 не сдвигается, то есть она всё ещё может быть потеряна.

Проверки (все точки отмены)

Согласованность данных:

  1. чтение удалённого или никогда не писанного объекта обязано вернуть -ENOENT;
  2. чтение обязано вернуть ровно подтверждённое содержимое: retval == len и побайтовое совпадение по всему диапазону; первая расходящаяся 32-байтная запись печатается с obj/seq/version/offset и ожидаемым значением — то есть устаревшие данные сами называют своего автора;
  3. внешний битмап в ответе обязан принадлежать именно возвращённой версии («returned the bitmap of another version»);
  4. после отказа питания или перезапуска чтение отвечает либо целым объектом, либо -ENOENT — любой иной retval означает, что на диск попало порванное содержимое;
  5. то, что объект «вернулся», обязано быть содержимым версии не ниже гарантированной границы: последней durable — после отказа питания, последней подтверждённой (последней synced при волатильном кэше) — после перезапуска, который ничего не потерял. Иначе — падение с «came back … holding unexpected content» и диффом по каждой версии-кандидату: та, что почти совпадает, обычно и показывает, какая запись потерялась или применилась не в том порядке;
  6. объект вправе полностью исчезнуть только тогда, когда ничего не гарантировалось; тогда модель сбрасывается, и нумерация начинается с 1;
  7. при immediate_commit=all чистая перезагрузка обязана вернуть ровно то же состояние — тот же retval, та же версия, те же байты; в сообщении о падении печатается ещё и первый никогда не писанный сектор журнала, который прямо указывает на потерянную запись;
  8. вернувшееся содержимое становится новой базой, но не новой durable-точкой: durable-содержимое остаётся легальным исходом, can_rollback сбрасывается, и следующий отказ всё ещё может откатить объект к durable-версии;
  9. rollback вправе удалить только версии выше стабильной, и счётчик версий откатывается соответственно — иначе это заметят последующие записи и чтения;
  10. финальные чтения целых объектов обязаны совпасть с подтверждённым содержимым.

Живость:

  1. «operations did not complete in 1000000 steps — deadlock?» — зависшие операции печатаются вместе с диагностикой хранилища (completed/fsynced/compacted LSN, очередь compact) в v2;
  2. «blockstore failed to start»;
  3. «a read of object N did not complete in MAX_STEPS — deadlock?» и «the final read of object N did not complete»;
  4. «disk did not go idle before a clean restart» — защита от use-after-free: ни один запрос не может пережить хранилище, которое его послало.

Учёт отказов:

  1. отказом записи/удаления принимается только -EAGAIN, упавший sync трактуется как «ничего не синхронизировал», а любая другая ошибка — включая stabilize/rollback — обрывает тест.

Защиты тестовых заглушек:

  1. неожиданный fd в заглушке ringloop, отклонённый SQE, --max-inflight < 1;
  2. перекрывающиеся одновременные записи: запись, отправленная, пока в полёте находится другая, накрывающая часть её диапазона, — падение теста. Устройство вправе применить такую пару в любом порядке, поэтому полагаться на то, что победит более новая, — ошибка; ловить это на отправке детерминированно лучше, чем ждать неудачного порядка завершения.

Отчёт и воспроизводимость

По умолчанию тест прогоняет 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.