Data integrity audit using AI (Claude Opus 4.8 + ultracode) #72

Open
opened 2026-05-29 03:51:00 +03:00 by maxpain · 1 comment

Аудит Vitastor 3.0.12 — баги, способные привести к повреждению/потере данных

Версия: 3.0.12 (VITASTOR_VERSION), коммит b4aab7a7.
Объём: разобраны пути данных нового heap-движка (blockstore_heap.*, формат метаданных v3),
старого движка (blockstore/v1/*), OSD/EC (osd_rmw, osd_primary*, osd_peering*, osd_scrub)
и клиента (cluster_client*).

Методология. Каждый файл читался целиком; каждая находка независимо перепроверялась на
3 вопроса — «это by-design?», «путь достижим в проде?», «порча реально доходит до диска/клиента?».
Учтена однопоточная модель event-loop (нет преемптивных гонок основной логики), контракт «нет
двух параллельных записей в один объект» и то, что откат нестабильных версий при peering — это by design.
Один кандидат (неполный листинг объектов в cluster_client_list.cpp при деградации PG) проверен и
отклонён как ложноположительный (поведение by-design: вызывающий код в cli_merge корректно
обрабатывает down-OSD). Остальные ниже — подтверждённые.

Серьёзность: ? тихая порча/потеря данных · ? неверные данные клиенту / durability · ? крах/прочее.


? 1. finish_load(): неверный оператор границы объекта склеивает разные объекты в одну цепочку

Файл: src/blockstore/blockstore_heap.cpp:560-569 (корень — 563-564), нарушается инвариант
insert_list_items() (1466-1512). Движок: heap (v3). Тип: тихая порча / потеря данных при рестарте OSD.

При старте записи, требующие более POSTPONE_INSERT_COUNT (10) пропусков при вставке, откладываются
в postponed_items. finish_load() сортирует их по (inode ASC, stripe ASC, lsn DESC) и должен резать
на пакеты по объектам, вызывая insert_list_items() один раз на объект. Условие границы:

if (e >= n || postponed_items[e]->entry.inode != postponed_items[s]->entry.inode &&
    postponed_items[e]->entry.stripe != postponed_items[s]->entry.stripe)

Из-за приоритета операторов это ... || ((inode разные) && (stripe разные)) — граница ставится
только когда отличаются И inode, И stripe. Два разных объекта с одним inode (любой образ > 1 блока) или с одним stripe не разделяются и попадают в один вызов insert_list_items().
А insert_list_items() жёстко рассчитан на один объект: индекс/голову цепочки он ищет один раз по
v[0] (1468-1473), а is_before() сравнивает только lsn (без inode/stripe). В результате записи
объекта B вшиваются по lsn в цепочку объекта A, а голова B в inode_map не обновляется.

Сценарий. Образ (inode) с ≥2 объектами (stripe), у одного из stripe накопилось >~11 несжатых
метазаписей (обычное дело при small-write нагрузке / immediate_commit=none, особенно после
неаккуратного рестарта с отстающей компакцией). OSD перезапускается → после загрузки чтение
объекта возвращает данные/версию другого объекта, либо часть объектов исчезает из индекса.

validate_object() (459-547) проверяет только порядок lsn/версий, но не проверяет совпадение
inode/stripe в цепочке, поэтому склейку не ловит. Per-entry crc32c проходит (байты целы — версия
«не та»).

Подтверждено 3/3 независимыми проверками, в т.ч. эмпирически: при сборке реального blockstore_heap
и прогоне load_blocks()/finish_load() с двумя stripe одного inode цепочки склеивались; замена
&&→|| устраняла дефект и проходила test_heap. Регрессия введена коммитом a0c8be46 (до него
вставка шла поэлементно по собственным inode/stripe).

Фикс: заменить && на || в строках 563-564.


? 2. V1 fsync_batch(): ожидающая корутина проходит барьер, когда fsync лишь ОТПРАВЛЕН, но ещё не durable

Файл: src/blockstore/v1/flush.cpp:1352 (гейт), сайты вызова 539, 604, 896.
Движок: v1 (legacy). Тип: нарушение durability / тихая порча при крахе.

Батч fsync синхронизирует несколько корутин-флашеров через cur_sync->state
(0=ожидание, 1=fsync отправлен в io_uring, 2=CQE получен = durable). Корутина-submitter ставит
state=1, отправляет fsync и паркуется до своего CQE, затем ставит state=2. Корутина-waiter
паркуется на wait_base+1 и при повторном входе проверяет гейт:

resume_1:
    if (!cur_sync->state) { ... }   // строка 1352
    // иначе — проваливается мимо барьера: syncing_flushers--; ready_count--; return true;

if (!state) считает «готово» и state==1, и state==2. Но ring_loop_t::loop() на каждой итерации
сначала собирает готовые CQE, затем безусловно прогоняет все consumer'ы, и блочный consumer вызывает
co[i].loop() для всех активных флашеров (v1/impl.cpp). Значит при любом постороннем completion'е
(запись сектора журнала клиента, данные другого флашера, чтение) в окне между парковкой submitter'а
(state==1) и получением CQE fsync'а, waiter повторно входит в resume_1, видит state==1 и
проходит барьер до фактического fsync'а данных.

Сценарий (наиболее опасный — раздельные устройства data/meta, типичная конфигурация HDD-data + NVMe-meta,
immediate_commit=none/small, ≥2 активных флашера):
флашер копирует данные small-write в чистый блок
на медленном HDD и доходит до fsync_batch(data); submitter отправляет медленный fsync HDD (state=1)
и паркуется; быстрый посторонний completion на NVMe повторно входит в waiter → waiter проходит барьер и
идёт писать метаданные, помечая блок чистым/валидным. Если fsync данных ещё не лёг на HDD и случается
крах — метаданные durable, данные нет → при восстановлении блок считается валидным, но содержит старьё/мусор
→ тихая порча. Аналогично для meta→trim-журнала: журнал тримится (освобождается) до durable-метаданных.

Подтверждено 3/3.

Фикс: ждать durable, а не submitted — waiter при state==1 должен снова парковаться; гейт
изменить на условие «продолжать только при state==2».


? 3. V1 read_range_fulfilled(): разупорядоченная вставка zero-fill отдаёт чужие байты с диска при проходящей чексумме

Файл: src/blockstore/v1/read.cpp:233-263 (корень — insert на 245); проявляется в
read_checksum_block (266-382). Движок: v1. Тип: тихая порча + утечка данных между тенантами.

При csum_block_size > bitmap_granularity чистое чтение идёт через fulfill_clean_read → fill_partial_checksum_blocks → read_range_fulfilled. Для каждого csum-блока функция сканирует битмап
и вставляет COPY_BUF_ZERO сегменты для неаллоцированных гранул через rv.insert(rv.begin()+pos, el).
pos фиксируется один раз на вызов callback'а, но внутренний цикл может вставить несколько zero-сегментов
за один вызов (битмап вида data…hole…data внутри одного csum-блока). Все вставки идут по одному и тому же
pos, поэтому второй (с большим offset) сегмент оказывается перед первым → read_vec теряет
сортировку по offset (проверено симуляцией: битмап 0b1010 на 16K-блоке даёт rv=[ZERO@8192, ZERO@0]).
find_holes() предполагает отсортированный read_vec и сканирует линейно, поэтому нижний ZERO-сегмент,
оказавшийся на бо́льшем индексе, не достигается: его диапазон классифицируется как «дыра» и сливается с
предыдущей дырой. read_checksum_block затем читает всю слитую дыру (включая зону нулей) прямо с
data-устройства в op->buf, затирая уже сделанный memset нулей → клиент получает старые байты с диска
(например, от ранее удалённого объекта)
вместо нулей.

Чексумма не ловит это: verify_*_padded_checksums пересчитывает чексумму блока по битмапу, подставляя
нули на месте дыр — то есть проверяет ожидаемое (нулевое) содержимое, а не то, что реально вернулось клиенту.

Сценарий: data_csum_type=crc32c, csum_block_size=32k, bitmap_granularity=4k. Объект с битмапом
вида «аллоцировано / дыра / аллоцировано» в одном csum-блоке (запись 4k@4k и 4k@12k, sync, flush).
Чтение всего csum-блока → нижняя неаллоцированная зона читается с диска. В сборке с assert'ами — abort на
read.cpp:491 (потеря доступности), без assert'ов — клиенту уходят чужие данные.

Подтверждено 3/3. Для multi-tenant IaaS — это ещё и утечка данных между клиентами.

Фикс: инкрементировать индекс при каждой вставке (rv.begin() + pos++), чтобы несколько zero-сегментов
вставлялись по возрастанию offset и read_vec оставался отсортированным.


? 4. mark_partial_write() не распознаёт собственную (локальную) запись primary → хорошая копия помечается LOC_OUTDATED

Файл: src/osd/osd_primary.cpp:449-458 (вызов из osd_primary_write.cpp:266),
локальная ветка osd_primary_subops.cpp:199-228. Тип: неверные данные / недоступность (реплицированные пулы).

При частичном сбое записи (например, ENOSPC на одной реплике) для реплицированного пула вызывается
mark_partial_write(), помечающий не подтверждённые как успешные чанки LOC_OUTDATED:

for (int i = 0; i < op_data->n_subops; i++)
    if (op_data->subops[i].osd_num == chunk.osd_num &&
        op_data->subops[i].reply.hdr.retval == op_data->subops[i].req.sec_rw.len)
        { success = true; break; }

Локальный subop (запись на самом primary) отправляется в blockstore (bs->enqueue_op), а не через
submit_to_osd(), поэтому его osd_num остаётся 0 (дефолт osd_op_t::osd_num=0, msgr_op.h:160;
handle_primary_bs_subop его не проставляет). Для чанка primary chunk.osd_num == this->osd_num (реальный
ненулевой номер), и сравнение subops[i].osd_num(0) == chunk.osd_num никогда не совпадает → успешная
локальная запись primary не засчитывается → хорошая копия primary помечается LOC_OUTDATED.

Последствие: объект, реально записанный на primary, считается устаревшим. Чтение может вернуть EIO
(нет «свежей» копии) для читаемых данных, либо отдать старые данные с реплики; при последующем восстановлении
свежие данные на primary рискуют быть перетёрты старой копией → потеря обновления.

Подтверждено 3/3. Дефект в текущем коде: success-проверка добавлена недавним коммитом 572b20fe
(«Fix mark_partial_write for replicas», с пометкой автора «TODO: try to write an OSD unit test for it»),
но не учитывает osd_num==0 локального subop.

Фикс: проставлять локальному subop osd_num = this->osd_num, либо в mark_partial_write отдельно
учитывать случай chunk.osd_num == this->osd_num (успех локальной записи по bs_op->retval).


? 5. iterate_with_stable() / iterate_compaction(): более новый ROLLBACK к меньшей версии «воскрешается» более старым ROLLBACK к большей версии

Файл: src/blockstore/blockstore_heap.cpp:2041 (и идентично в iterate_compaction — 2093/2108).
Движок: heap (v3). Тип: неверные данные клиенту / завышение unstable-набора при peering.

Обход цепочки идёт newest→oldest; rollback_version фильтрует откаченные записи
(if (old_wr->version > rollback_version) continue;). Но на каждом ROLLBACK выполняется
безусловная перезапись rollback_version = old_wr->version; (2041). Если в цепочке два ROLLBACK,
где более новый откатывает к меньшей версии, чем более старый, то старый ROLLBACK поднимает
порог обратно вверх, и версии, durable-откаченные новым ROLLBACK, снова проходят фильтр.

Пример цепочки (newest→oldest): ROLLBACK(v2) ROLLBACK(v3) BIG(v4) BIG(v3) BIG(v2) BIG(v1,STABLE).
Обход: rollback_version=2, затем перезапись =3; далее BIG(v4) (4>3 — скрыт), а BIG(v3) (3>3 ложно)
всплывает как живая версия, хотя ROLLBACK(v2) авторитетно отбросил всё выше v2.

Это потребляется путём чтения (blockstore_read.cpp — выбирается первая всплывшая версия ≤ запрошенной)
и list_objects() (2209), который отдаёт «воскрешённую» версию в peering как присутствующую unstable →
завышает unstable-набор, и восстановление может сохранить/выбрать версию, которую кластер durable-отбросил.

Что это дефект, а не by-design: add_commit() (1762-1772) и validate_object() (503) ту же цепочку
читают корректно (используют локально-ограниченную логику и считают v3/v4 откаченными), то есть три
функции читают одну и ту же durable-цепочку и расходятся — выбивается именно iterate_with_stable/iterate_compaction.
Сам комментарий в коде (2050-2055, «case 5: 1 2 3 4 5 ROLLBACK(4) 5 ROLLBACK(3)») перечисляет такие
чередующиеся откаты как случай, который функция обязана обрабатывать.

Подтверждено 2/3 (один проверяющий — «uncertain»): unit-дефект воспроизведён эмпирически на реальном коде
(всплывает v3/v4); надёжно достижим путь list_objects→peering. Точный продовый триггер (два отката с
понижением целевой версии подряд) правдоподобен, но не доказан строго — отсюда средняя уверенность.

Фикс: не повышать rollback_version более старым ROLLBACK (например, обрабатывать откаты так же
локально-ограниченно, как в add_commit, либо rollback_version = min(rollback_version, old_wr->version)
с корректным учётом порядка).


? 6. continue_sync() очищает dirty_osds до подтверждения успеха → повтор после сбоя SYNC может ложно отрапортовать успех

Файл: src/client/cluster_client.cpp:1501 (clear), 1467-1472 (ранний успех), 1508-1520 (повтор).
Тип: durability-hazard (клиент).

continue_sync() рассылает sync на dirty_osds, затем сразу dirty_osds.clear() (1501). При ошибке
части sync с -EPIPE/-EIO/-ENOSPC (1511) ветка повтора сбрасывает op->state=0 и возвращает 0, не
восстанавливая dirty_osds
. При повторном входе срабатывает ранний выход if (!dirty_osds.size())
(1467) → retval=0, SYNC рапортует успех, хотя данные не зафиксированы.

Подтверждено: дубль-находка получила 3/3; в основной — 2/3 (один проверяющий обоснованно отметил, что
blockstore при ошибке fsync вызывает disk_error_abort()→exit(), поэтому SYNC по живому соединению почти
не вернёт -EIO/-ENOSPC — реалистичен сценарий -EPIPE/разрыв соединения, где безопасность зависит от
того, успеют ли перезапросы записей заново наполнить dirty_osds до повтора SYNC). Это defensive-фикс
в durability-пути: код не гарантирует корректность, полагаясь на тайминг перезапроса.

Фикс: не очищать dirty_osds до подтверждённого успеха всех sync, либо восстанавливать набор на ветке
повтора (1508-1520).


? 7. Компакция-в-удаление разыменовывает NULL clean_wr → детерминированный crash-loop OSD

Файл: src/blockstore/blockstore_flush.cpp:298 (первое разыменование; также 295-296, 301, 306, 315);
отсутствует ветка по compact_info.do_delete. Движок: heap (v3). Тип: крах (не порча данных), но
crash-loop по durable-метаданным → OSD не стартует.

iterate_compaction() штатно возвращает {do_delete=true, compact_lsn≠0, clean_wr=NULL} (контракт
подтверждён комментарием 2072-2073 и юнит-тестом test_iterate_compaction). В journal_flusher_co::loop
единственный ранний выход после неё — if (!compact_info.compact_lsn) (272); при compact_lsn≠0 он
проходит, и на строке 298 compact_info.clean_wr->get_int_bitmap(...) разыменовывает NULL → SIGSEGV.
do_delete используется только позже (453, add_compact), куда поток не доходит.

Сценарий: объект с историей BIG(v1,STABLE) → DELETE → нестабильный BIG(v1) → ROLLBACK(v0)
(удаление, затем нестабильная перезапись, затем откат при peering). ROLLBACK is_compactable → объект
в очереди компакции; при компакции — segfault. После рестарта состояние durable, объект снова ставится
в очередь → OSD падает на каждом старте до ручного вмешательства (skip_corrupted_meta_entries /
удаление объекта).

Подтверждено 3/3 (один проверяющий воспроизвёл краш под ASan). Порча на диск не пишется (segfault до
записей), но availability-эффект критичен для прода.

Фикс: добавить if (compact_info.do_delete) — пропустить вычисления на основе clean_wr (295-315)
и идти сразу к add_compact (452-453), который корректно обрабатывает do_delete без clean_wr.


? 8. Буферизованный DELETE при включённом writeback подтверждается как успех, но не выполняется

Файл: src/client/cluster_client.cpp:774-792; ранний выход copy_write для len==0 —
src/client/cluster_client_wb.cpp:89-97 (там же явный FIXME). Тип: удаление теряется (остаточные данные).

При enable_writeback==true (не по умолчанию) и без OP_FLUSH_BUFFER ветка writeback вызывает
wb->copy_write(op, CACHE_DIRTY), который для len==0 (DELETE) сразу выходит, ничего не кэшируя и не
отправляя
, после чего op->retval=op->len(=0) и вызывается success-callback. Удаление подтверждено
клиенту, но не записано/не отправлено ни на один OSD → объект остаётся.

Последствие: «удалённые» данные сохраняются (для IaaS — остаточные данные/несоответствие учёта),
последующее чтение вернёт старые данные.

Подтверждено 2/3 (один — «uncertain» из-за non-default writeback). Требует enable_writeback.

Фикс: обработать len==0/DELETE в copy_write (реализовать FIXME), а не подтверждать как успех.


? 9. scrub: сброс флага LOC_CORRUPTED индексирует stripes[] по EC-роли вместо индекса массива

Файл: src/osd/osd_scrub.cpp:630. Тип: устойчивость/краш (без порчи данных).

В callback'е mark_object ветка снятия устаревшего LOC_CORRUPTED использует
op_data->stripes[chunk.role].missing, тогда как нужно op_data->stripes[i].missing. Для «чистого»
scrub-пути stripes[i].role==i, и всё корректно; но при не-чистом состоянии объекта (degraded/misplaced/
corrupted, object_state!=NULL) массив stripes[] упакован и stripes[i].role != i. Тогда читается
.missing чужого stripe, а при chunk.role >= stripe_count — выход за границы calloc-массива
(heap over-read), вплоть до падения OSD.

Подтверждено 3/3, но сами проверяющие заключили: тихой порчи данных нет (реально повреждённый чанк всегда
имеет read_error==true и идёт по ветке установки флага). Это robustness/crash-баг.

Фикс: индексировать op_data->stripes[i] вместо op_data->stripes[chunk.role].


Резюме

# Файл Серьёзность Кратко
1 heap finish_load ? порча/потеря склейка объектов при рестарте
2 v1 fsync_batch ? порча барьер fsync пропускается до durable
3 v1 read_range_fulfilled ? порча/утечка чужие байты вместо нулей, чексумма не ловит
4 mark_partial_write ? неверные данные локальная запись primary не засчитывается
5 heap iterate_with_stable ? неверные данные воскрешение откаченной версии
6 continue_sync ? durability ложный успех SYNC после сбоя
7 heap flush (do_delete) ? crash-loop NULL-разыменование, OSD не стартует
8 writeback DELETE ? потеря удаления delete не выполняется (non-default wb)
9 osd_scrub ? краш OOB-чтение по EC-роли

Самые приоритетные: #1, #2, #3 (тихая порча данных) и #4 (неверные данные на реплицированных пулах).
Находки #1, #5, #7 — в новом heap-движке (формат v3); #2, #3 — в legacy-движке v1; остальные — в OSD/клиенте.

# Аудит Vitastor 3.0.12 — баги, способные привести к повреждению/потере данных **Версия:** 3.0.12 (`VITASTOR_VERSION`), коммит `b4aab7a7`. **Объём:** разобраны пути данных нового heap-движка (`blockstore_heap.*`, формат метаданных v3), старого движка (`blockstore/v1/*`), OSD/EC (`osd_rmw`, `osd_primary*`, `osd_peering*`, `osd_scrub`) и клиента (`cluster_client*`). **Методология.** Каждый файл читался целиком; каждая находка независимо перепроверялась на 3 вопроса — «это by-design?», «путь достижим в проде?», «порча реально доходит до диска/клиента?». Учтена однопоточная модель event-loop (нет преемптивных гонок основной логики), контракт «нет двух параллельных записей в один объект» и то, что откат нестабильных версий при peering — это by design. Один кандидат (неполный листинг объектов в `cluster_client_list.cpp` при деградации PG) проверен и **отклонён** как ложноположительный (поведение by-design: вызывающий код в `cli_merge` корректно обрабатывает down-OSD). Остальные ниже — подтверждённые. Серьёзность: ? тихая порча/потеря данных · ? неверные данные клиенту / durability · ? крах/прочее. --- ## ? 1. `finish_load()`: неверный оператор границы объекта склеивает разные объекты в одну цепочку **Файл:** `src/blockstore/blockstore_heap.cpp:560-569` (корень — `563-564`), нарушается инвариант `insert_list_items()` (`1466-1512`). **Движок:** heap (v3). **Тип:** тихая порча / потеря данных при рестарте OSD. При старте записи, требующие более `POSTPONE_INSERT_COUNT` (10) пропусков при вставке, откладываются в `postponed_items`. `finish_load()` сортирует их по `(inode ASC, stripe ASC, lsn DESC)` и должен резать на пакеты **по объектам**, вызывая `insert_list_items()` один раз на объект. Условие границы: ```cpp if (e >= n || postponed_items[e]->entry.inode != postponed_items[s]->entry.inode && postponed_items[e]->entry.stripe != postponed_items[s]->entry.stripe) ``` Из-за приоритета операторов это `... || ((inode разные) && (stripe разные))` — граница ставится только когда отличаются **И** inode, **И** stripe. Два разных объекта с одним inode (любой образ > 1 блока) или с одним stripe не разделяются и попадают в один вызов `insert_list_items()`. А `insert_list_items()` жёстко рассчитан на один объект: индекс/голову цепочки он ищет один раз по `v[0]` (1468-1473), а `is_before()` сравнивает только `lsn` (без `inode/stripe`). В результате записи объекта B вшиваются по lsn в цепочку объекта A, а голова B в `inode_map` не обновляется. **Сценарий.** Образ (inode) с ≥2 объектами (stripe), у одного из stripe накопилось >~11 несжатых метазаписей (обычное дело при small-write нагрузке / `immediate_commit=none`, особенно после неаккуратного рестарта с отстающей компакцией). OSD перезапускается → после загрузки чтение объекта возвращает данные/версию **другого** объекта, либо часть объектов исчезает из индекса. `validate_object()` (459-547) проверяет только порядок lsn/версий, но **не** проверяет совпадение `inode/stripe` в цепочке, поэтому склейку не ловит. Per-entry crc32c проходит (байты целы — версия «не та»). > Подтверждено 3/3 независимыми проверками, в т.ч. эмпирически: при сборке реального `blockstore_heap` > и прогоне `load_blocks()/finish_load()` с двумя stripe одного inode цепочки склеивались; замена > `&&`→`||` устраняла дефект и проходила `test_heap`. Регрессия введена коммитом `a0c8be46` (до него > вставка шла поэлементно по собственным `inode/stripe`). **Фикс:** заменить `&&` на `||` в строках 563-564. --- ## ? 2. V1 `fsync_batch()`: ожидающая корутина проходит барьер, когда fsync лишь ОТПРАВЛЕН, но ещё не durable **Файл:** `src/blockstore/v1/flush.cpp:1352` (гейт), сайты вызова `539`, `604`, `896`. **Движок:** v1 (legacy). **Тип:** нарушение durability / тихая порча при крахе. Батч fsync синхронизирует несколько корутин-флашеров через `cur_sync->state` (0=ожидание, 1=fsync отправлен в io_uring, 2=CQE получен = durable). Корутина-submitter ставит `state=1`, отправляет fsync и паркуется до своего CQE, затем ставит `state=2`. Корутина-waiter паркуется на `wait_base+1` и при повторном входе проверяет гейт: ```cpp resume_1: if (!cur_sync->state) { ... } // строка 1352 // иначе — проваливается мимо барьера: syncing_flushers--; ready_count--; return true; ``` `if (!state)` считает «готово» и `state==1`, и `state==2`. Но `ring_loop_t::loop()` на каждой итерации сначала собирает готовые CQE, затем безусловно прогоняет все consumer'ы, и блочный consumer вызывает `co[i].loop()` для всех активных флашеров (`v1/impl.cpp`). Значит при **любом постороннем** completion'е (запись сектора журнала клиента, данные другого флашера, чтение) в окне между парковкой submitter'а (`state==1`) и получением CQE fsync'а, waiter повторно входит в `resume_1`, видит `state==1` и **проходит барьер до фактического fsync'а данных**. **Сценарий (наиболее опасный — раздельные устройства data/meta, типичная конфигурация HDD-data + NVMe-meta, `immediate_commit=none/small`, ≥2 активных флашера):** флашер копирует данные small-write в чистый блок на медленном HDD и доходит до `fsync_batch(data)`; submitter отправляет медленный fsync HDD (`state=1`) и паркуется; быстрый посторонний completion на NVMe повторно входит в waiter → waiter проходит барьер и идёт писать **метаданные**, помечая блок чистым/валидным. Если fsync данных ещё не лёг на HDD и случается крах — метаданные durable, данные нет → при восстановлении блок считается валидным, но содержит старьё/мусор → **тихая порча**. Аналогично для meta→trim-журнала: журнал тримится (освобождается) до durable-метаданных. > Подтверждено 3/3. **Фикс:** ждать durable, а не submitted — waiter при `state==1` должен снова парковаться; гейт изменить на условие «продолжать только при `state==2`». --- ## ? 3. V1 `read_range_fulfilled()`: разупорядоченная вставка zero-fill отдаёт чужие байты с диска при проходящей чексумме **Файл:** `src/blockstore/v1/read.cpp:233-263` (корень — `insert` на `245`); проявляется в `read_checksum_block` (266-382). **Движок:** v1. **Тип:** тихая порча + утечка данных между тенантами. При `csum_block_size > bitmap_granularity` чистое чтение идёт через `fulfill_clean_read → fill_partial_checksum_blocks → read_range_fulfilled`. Для каждого csum-блока функция сканирует битмап и вставляет `COPY_BUF_ZERO` сегменты для неаллоцированных гранул через `rv.insert(rv.begin()+pos, el)`. `pos` фиксируется один раз на вызов callback'а, но внутренний цикл может вставить **несколько** zero-сегментов за один вызов (битмап вида data…hole…data внутри одного csum-блока). Все вставки идут по одному и тому же `pos`, поэтому второй (с большим offset) сегмент оказывается **перед** первым → `read_vec` теряет сортировку по offset (проверено симуляцией: битмап `0b1010` на 16K-блоке даёт `rv=[ZERO@8192, ZERO@0]`). `find_holes()` предполагает отсортированный `read_vec` и сканирует линейно, поэтому нижний ZERO-сегмент, оказавшийся на бо́льшем индексе, не достигается: его диапазон классифицируется как «дыра» и сливается с предыдущей дырой. `read_checksum_block` затем читает **всю** слитую дыру (включая зону нулей) прямо с data-устройства в `op->buf`, затирая уже сделанный memset нулей → клиент получает **старые байты с диска (например, от ранее удалённого объекта)** вместо нулей. Чексумма **не ловит** это: `verify_*_padded_checksums` пересчитывает чексумму блока по битмапу, подставляя нули на месте дыр — то есть проверяет ожидаемое (нулевое) содержимое, а не то, что реально вернулось клиенту. **Сценарий:** `data_csum_type=crc32c`, `csum_block_size=32k`, `bitmap_granularity=4k`. Объект с битмапом вида «аллоцировано / дыра / аллоцировано» в одном csum-блоке (запись 4k@4k и 4k@12k, sync, flush). Чтение всего csum-блока → нижняя неаллоцированная зона читается с диска. В сборке с assert'ами — abort на `read.cpp:491` (потеря доступности), без assert'ов — клиенту уходят чужие данные. > Подтверждено 3/3. Для multi-tenant IaaS — это ещё и утечка данных между клиентами. **Фикс:** инкрементировать индекс при каждой вставке (`rv.begin() + pos++`), чтобы несколько zero-сегментов вставлялись по возрастанию offset и `read_vec` оставался отсортированным. --- ## ? 4. `mark_partial_write()` не распознаёт собственную (локальную) запись primary → хорошая копия помечается LOC_OUTDATED **Файл:** `src/osd/osd_primary.cpp:449-458` (вызов из `osd_primary_write.cpp:266`), локальная ветка `osd_primary_subops.cpp:199-228`. **Тип:** неверные данные / недоступность (реплицированные пулы). При частичном сбое записи (например, ENOSPC на одной реплике) для реплицированного пула вызывается `mark_partial_write()`, помечающий не подтверждённые как успешные чанки `LOC_OUTDATED`: ```cpp for (int i = 0; i < op_data->n_subops; i++) if (op_data->subops[i].osd_num == chunk.osd_num && op_data->subops[i].reply.hdr.retval == op_data->subops[i].req.sec_rw.len) { success = true; break; } ``` Локальный subop (запись на самом primary) отправляется в blockstore (`bs->enqueue_op`), а не через `submit_to_osd()`, поэтому его `osd_num` остаётся **0** (дефолт `osd_op_t::osd_num=0`, `msgr_op.h:160`; `handle_primary_bs_subop` его не проставляет). Для чанка primary `chunk.osd_num == this->osd_num` (реальный ненулевой номер), и сравнение `subops[i].osd_num(0) == chunk.osd_num` **никогда не совпадает** → успешная локальная запись primary не засчитывается → хорошая копия primary помечается `LOC_OUTDATED`. **Последствие:** объект, реально записанный на primary, считается устаревшим. Чтение может вернуть `EIO` (нет «свежей» копии) для читаемых данных, либо отдать старые данные с реплики; при последующем восстановлении свежие данные на primary рискуют быть перетёрты старой копией → потеря обновления. > Подтверждено 3/3. Дефект в текущем коде: `success`-проверка добавлена недавним коммитом `572b20fe` > («Fix mark_partial_write for replicas», с пометкой автора «TODO: try to write an OSD unit test for it»), > но не учитывает `osd_num==0` локального subop. **Фикс:** проставлять локальному subop `osd_num = this->osd_num`, либо в `mark_partial_write` отдельно учитывать случай `chunk.osd_num == this->osd_num` (успех локальной записи по `bs_op->retval`). --- ## ? 5. `iterate_with_stable()` / `iterate_compaction()`: более новый ROLLBACK к меньшей версии «воскрешается» более старым ROLLBACK к большей версии **Файл:** `src/blockstore/blockstore_heap.cpp:2041` (и идентично в `iterate_compaction` — `2093`/`2108`). **Движок:** heap (v3). **Тип:** неверные данные клиенту / завышение unstable-набора при peering. Обход цепочки идёт newest→oldest; `rollback_version` фильтрует откаченные записи (`if (old_wr->version > rollback_version) continue;`). Но на каждом ROLLBACK выполняется **безусловная** перезапись `rollback_version = old_wr->version;` (2041). Если в цепочке два ROLLBACK, где более **новый** откатывает к **меньшей** версии, чем более старый, то старый ROLLBACK поднимает порог обратно вверх, и версии, durable-откаченные новым ROLLBACK, снова проходят фильтр. Пример цепочки (newest→oldest): `ROLLBACK(v2) ROLLBACK(v3) BIG(v4) BIG(v3) BIG(v2) BIG(v1,STABLE)`. Обход: `rollback_version=2`, затем перезапись `=3`; далее `BIG(v4)` (4>3 — скрыт), а `BIG(v3)` (3>3 ложно) **всплывает как живая версия**, хотя `ROLLBACK(v2)` авторитетно отбросил всё выше v2. Это потребляется путём чтения (`blockstore_read.cpp` — выбирается первая всплывшая версия ≤ запрошенной) и `list_objects()` (2209), который отдаёт «воскрешённую» версию в peering как присутствующую unstable → завышает unstable-набор, и восстановление может сохранить/выбрать версию, которую кластер durable-отбросил. Что это **дефект, а не by-design**: `add_commit()` (1762-1772) и `validate_object()` (503) ту же цепочку читают **корректно** (используют локально-ограниченную логику и считают v3/v4 откаченными), то есть три функции читают одну и ту же durable-цепочку и расходятся — выбивается именно `iterate_with_stable`/`iterate_compaction`. Сам комментарий в коде (2050-2055, «case 5: 1 2 3 4 5 ROLLBACK(4) 5 ROLLBACK(3)») перечисляет такие чередующиеся откаты как случай, который функция обязана обрабатывать. > Подтверждено 2/3 (один проверяющий — «uncertain»): unit-дефект воспроизведён эмпирически на реальном коде > (всплывает v3/v4); надёжно достижим путь `list_objects→peering`. Точный продовый триггер (два отката с > понижением целевой версии подряд) правдоподобен, но не доказан строго — отсюда **средняя** уверенность. **Фикс:** не повышать `rollback_version` более старым ROLLBACK (например, обрабатывать откаты так же локально-ограниченно, как в `add_commit`, либо `rollback_version = min(rollback_version, old_wr->version)` с корректным учётом порядка). --- ## ? 6. `continue_sync()` очищает `dirty_osds` до подтверждения успеха → повтор после сбоя SYNC может ложно отрапортовать успех **Файл:** `src/client/cluster_client.cpp:1501` (clear), `1467-1472` (ранний успех), `1508-1520` (повтор). **Тип:** durability-hazard (клиент). `continue_sync()` рассылает sync на `dirty_osds`, затем сразу `dirty_osds.clear()` (1501). При ошибке части sync с `-EPIPE/-EIO/-ENOSPC` (1511) ветка повтора сбрасывает `op->state=0` и возвращает 0, **не восстанавливая `dirty_osds`**. При повторном входе срабатывает ранний выход `if (!dirty_osds.size())` (1467) → `retval=0`, **SYNC рапортует успех**, хотя данные не зафиксированы. > Подтверждено: дубль-находка получила 3/3; в основной — 2/3 (один проверяющий обоснованно отметил, что > blockstore при ошибке fsync вызывает `disk_error_abort()→exit()`, поэтому SYNC по живому соединению почти > не вернёт `-EIO/-ENOSPC` — реалистичен сценарий `-EPIPE`/разрыв соединения, где безопасность зависит от > того, успеют ли перезапросы записей заново наполнить `dirty_osds` до повтора SYNC). Это **defensive-фикс** > в durability-пути: код не гарантирует корректность, полагаясь на тайминг перезапроса. **Фикс:** не очищать `dirty_osds` до подтверждённого успеха всех sync, либо восстанавливать набор на ветке повтора (1508-1520). --- ## ? 7. Компакция-в-удаление разыменовывает NULL `clean_wr` → детерминированный crash-loop OSD **Файл:** `src/blockstore/blockstore_flush.cpp:298` (первое разыменование; также 295-296, 301, 306, 315); отсутствует ветка по `compact_info.do_delete`. **Движок:** heap (v3). **Тип:** крах (не порча данных), но crash-loop по durable-метаданным → OSD не стартует. `iterate_compaction()` штатно возвращает `{do_delete=true, compact_lsn≠0, clean_wr=NULL}` (контракт подтверждён комментарием 2072-2073 и юнит-тестом `test_iterate_compaction`). В `journal_flusher_co::loop` единственный ранний выход после неё — `if (!compact_info.compact_lsn)` (272); при `compact_lsn≠0` он проходит, и на строке 298 `compact_info.clean_wr->get_int_bitmap(...)` разыменовывает NULL → SIGSEGV. `do_delete` используется только позже (453, `add_compact`), куда поток не доходит. **Сценарий:** объект с историей `BIG(v1,STABLE) → DELETE → нестабильный BIG(v1) → ROLLBACK(v0)` (удаление, затем нестабильная перезапись, затем откат при peering). ROLLBACK `is_compactable` → объект в очереди компакции; при компакции — segfault. После рестарта состояние durable, объект снова ставится в очередь → **OSD падает на каждом старте** до ручного вмешательства (`skip_corrupted_meta_entries` / удаление объекта). > Подтверждено 3/3 (один проверяющий воспроизвёл краш под ASan). Порча на диск не пишется (segfault до > записей), но availability-эффект критичен для прода. **Фикс:** добавить `if (compact_info.do_delete)` — пропустить вычисления на основе `clean_wr` (295-315) и идти сразу к `add_compact` (452-453), который корректно обрабатывает `do_delete` без `clean_wr`. --- ## ? 8. Буферизованный DELETE при включённом writeback подтверждается как успех, но не выполняется **Файл:** `src/client/cluster_client.cpp:774-792`; ранний выход `copy_write` для `len==0` — `src/client/cluster_client_wb.cpp:89-97` (там же явный `FIXME`). **Тип:** удаление теряется (остаточные данные). При `enable_writeback==true` (не по умолчанию) и без `OP_FLUSH_BUFFER` ветка writeback вызывает `wb->copy_write(op, CACHE_DIRTY)`, который для `len==0` (DELETE) **сразу выходит, ничего не кэшируя и не отправляя**, после чего `op->retval=op->len(=0)` и вызывается success-callback. Удаление подтверждено клиенту, но не записано/не отправлено ни на один OSD → объект остаётся. **Последствие:** «удалённые» данные сохраняются (для IaaS — остаточные данные/несоответствие учёта), последующее чтение вернёт старые данные. > Подтверждено 2/3 (один — «uncertain» из-за non-default writeback). Требует `enable_writeback`. **Фикс:** обработать `len==0`/DELETE в `copy_write` (реализовать `FIXME`), а не подтверждать как успех. --- ## ? 9. `scrub`: сброс флага LOC_CORRUPTED индексирует `stripes[]` по EC-роли вместо индекса массива **Файл:** `src/osd/osd_scrub.cpp:630`. **Тип:** устойчивость/краш (без порчи данных). В callback'е `mark_object` ветка снятия устаревшего `LOC_CORRUPTED` использует `op_data->stripes[chunk.role].missing`, тогда как нужно `op_data->stripes[i].missing`. Для «чистого» scrub-пути `stripes[i].role==i`, и всё корректно; но при не-чистом состоянии объекта (degraded/misplaced/ corrupted, `object_state!=NULL`) массив `stripes[]` упакован и `stripes[i].role != i`. Тогда читается `.missing` чужого stripe, а при `chunk.role >= stripe_count` — **выход за границы** `calloc`-массива (heap over-read), вплоть до падения OSD. > Подтверждено 3/3, но сами проверяющие заключили: тихой порчи данных нет (реально повреждённый чанк всегда > имеет `read_error==true` и идёт по ветке установки флага). Это robustness/crash-баг. **Фикс:** индексировать `op_data->stripes[i]` вместо `op_data->stripes[chunk.role]`. --- ### Резюме | # | Файл | Серьёзность | Кратко | |---|------|-------------|--------| | 1 | heap `finish_load` | ? порча/потеря | склейка объектов при рестарте | | 2 | v1 `fsync_batch` | ? порча | барьер fsync пропускается до durable | | 3 | v1 `read_range_fulfilled` | ? порча/утечка | чужие байты вместо нулей, чексумма не ловит | | 4 | `mark_partial_write` | ? неверные данные | локальная запись primary не засчитывается | | 5 | heap `iterate_with_stable` | ? неверные данные | воскрешение откаченной версии | | 6 | `continue_sync` | ? durability | ложный успех SYNC после сбоя | | 7 | heap flush (do_delete) | ? crash-loop | NULL-разыменование, OSD не стартует | | 8 | writeback DELETE | ? потеря удаления | delete не выполняется (non-default wb) | | 9 | `osd_scrub` | ? краш | OOB-чтение по EC-роли | Самые приоритетные: **#1, #2, #3** (тихая порча данных) и **#4** (неверные данные на реплицированных пулах). Находки #1, #5, #7 — в новом heap-движке (формат v3); #2, #3 — в legacy-движке v1; остальные — в OSD/клиенте.
Owner
  • 1 - да, исправил
  • 2 - это 1 из кими, уже исправлено
  • 3 - тут что-то хитрое, надо тест породить; пока чуть-чуть отложим, т.к. это blockstore v1 и прикрыто assert-ом, которые у меня в релизе не отключаются. т.е. в крайнем случае упадёт
  • 4 - вроде правда, исправил, сам кусок логики пока ждёт осд юнит тестов, чтобы его точно проверить
  • 5 - исправил, добавил тест
  • 6 - тут нет, оно про это много раз уже ругалось, но логика повторов на другом основана - на повторе буферов
  • 7 - исправил, добавил тест
  • 8 - нет, тоже уже было в прошлых заходах
  • 9 - тоже было в прошлых заходах
- 1 - да, исправил - 2 - это 1 из кими, уже исправлено - 3 - тут что-то хитрое, надо тест породить; пока чуть-чуть отложим, т.к. это blockstore v1 и прикрыто assert-ом, которые у меня в релизе не отключаются. т.е. в крайнем случае упадёт - 4 - вроде правда, исправил, сам кусок логики пока ждёт осд юнит тестов, чтобы его точно проверить - 5 - исправил, добавил тест - 6 - тут нет, оно про это много раз уже ругалось, но логика повторов на другом основана - на повторе буферов - 7 - исправил, добавил тест - 8 - нет, тоже уже было в прошлых заходах - 9 - тоже было в прошлых заходах
maxpain changed title from Data integrity review using AI (Claude 4.8 + ultracode) to Data integrity review using AI (Claude Opus 4.8 + ultracode) 2026-06-09 20:20:12 +03:00
maxpain changed title from Data integrity review using AI (Claude Opus 4.8 + ultracode) to Data integrity audit using AI (Claude Opus 4.8 + ultracode) 2026-06-09 20:23:34 +03:00
Sign in to join this conversation.
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vitalif/vitastor#72
No description provided.