Data integrity audit using AI (Claude Opus 4.8 + ultracode) #72
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Аудит 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()один раз на объект. Условие границы:Из-за приоритета операторов это
... || ((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 проходит (байты целы — версия«не та»).
Фикс: заменить
&&на||в строках 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и при повторном входе проверяет гейт: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-метаданных.
Фикс: ждать 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'ов — клиенту уходят чужие данные.Фикс: инкрементировать индекс при каждой вставке (
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:Локальный 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его не проставляет). Для чанка primarychunk.osd_num == this->osd_num(реальныйненулевой номер), и сравнение
subops[i].osd_num(0) == chunk.osd_numникогда не совпадает → успешнаялокальная запись primary не засчитывается → хорошая копия primary помечается
LOC_OUTDATED.Последствие: объект, реально записанный на primary, считается устаревшим. Чтение может вернуть
EIO(нет «свежей» копии) для читаемых данных, либо отдать старые данные с реплики; при последующем восстановлении
свежие данные на primary рискуют быть перетёрты старой копией → потеря обновления.
Фикс: проставлять локальному 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)») перечисляет такие
чередующиеся откаты как случай, который функция обязана обрабатывать.
Фикс: не повышать
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 рапортует успех, хотя данные не зафиксированы.Фикс: не очищать
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/удаление объекта).
Фикс: добавить
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 — остаточные данные/несоответствие учёта),
последующее чтение вернёт старые данные.
Фикс: обработать
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.
Фикс: индексировать
op_data->stripes[i]вместоop_data->stripes[chunk.role].Резюме
finish_loadfsync_batchread_range_fulfilledmark_partial_writeiterate_with_stablecontinue_syncosd_scrubСамые приоритетные: #1, #2, #3 (тихая порча данных) и #4 (неверные данные на реплицированных пулах).
Находки #1, #5, #7 — в новом heap-движке (формат v3); #2, #3 — в legacy-движке v1; остальные — в OSD/клиенте.
Data integrity review using AI (Claude 4.8 + ultracode)to Data integrity review using AI (Claude Opus 4.8 + ultracode)Data integrity review using AI (Claude Opus 4.8 + ultracode)to Data integrity audit using AI (Claude Opus 4.8 + ultracode)