ParticleDB заявляла себя как Postgres-совместимую базу с набором транзакционных режимов и репликацией через Raftпротокол консенсуса для согласованного воспроизведения журнала операций на нескольких узлах. Отчёт Jepsen интересен тем, что почти все найденные аномалии воспроизводились на одном узле, без сетевых сбоев и часто с единственным клиентом. Разбираем, какие режимы что обещали, как устроены рабочие нагрузки, какие классы ошибок нашлись и что это говорит о заявленной архитектуре.
Что обещали режимы транзакций
Документация ParticleDB сопоставляла каждому режиму модель согласованности. occ (он же occ-v3, occ-v2, row-2pl) обещал Snapshot Isolationмодель изоляции, в которой транзакция видит согласованный снимок базы на момент своего начала; zero-copy-occ (он же zocc) — тоже Snapshot Isolation; serializable-ssi (он же ssi) — Serializableуровень изоляции, при котором результат конкурентного выполнения транзакций эквивалентен некоторому последовательному; table-2pl (он же serializable) — Strong Serializableусиленный Serializable, при котором транзакции выполняются в порядке реального времени. В справке particledb start --help первый описан как «Adaptive SSI (Cahill 2008), PL-3 with optimistic semantics (v1 scaffolding — see SSI_ADAPTIVE_DESIGN.md)», второй — как «Exclusive table locks, full serialization (strictest)». Отдельно существовал режим fast с моделью «Unknown» и описанием «No locks, maximum OLTP throughput (unsafe under concurrency)».
Имена occ, occ-v3, occ-v2 и row-2pl обозначают один и тот же режим: это варианты названия, под которыми он фигурирует в разных версиях и интерфейсах, а не разные механизмы. Аналогично zocc — сокращение для zero-copy-occ, ssi — для serializable-ssi, а serializable в справке указывает на table-2pl. Различие между Serializable и Strong Serializable состоит в требовании реального порядка: Strong Serializable дополнительно требует, чтобы транзакции наблюдались в том порядке, в каком они выполнялись во времени.
Проблема в том, что документация не объясняла, что именно эти режимы гарантируют: «the documentation did not explain what these modes actually guaranteed». Имя serializable при этом меняло смысл от версии к версии: в 1.1.4–1.1.18.11.rc-2 это был либо алиас table-2pl, либо, возможно, occ-v3, а в 1.1.18.11.rc-22 он появился заново как новый режим Strong Serializable на основе SSI.
Что именно тестировалось: один узел, а не кластер
ParticleDB умела работать как одиночный узел и предлагала реплицированный кластер на протоколе консенсуса Raft, но репликация была доступна только в enterprise-облачных развёртываниях. Сборки, предоставленные Orkes, репликацию не поддерживали, поэтому отчёт покрывает только поведение одиночного узла: «The builds Orkes provided did not support replication, so this report covers only single-node behavior».
Стенд запускал версии 1.1.4–1.1.18.11.rc-22 на отдельных узлах Debian 13, в контейнерах и на EC2 VM. Он умел имитировать падение процесса (kill), потерю не-fsync-нутых записей через LazyFSинструмент, эмулирующий файловую систему с потерей записей, не сброшенных на диск через fsync и усечение либо порчу файлов на диске. Ключевой момент: аномалии возникали в здоровых одноузловых инстансах, без инъекции сбоев, часто с одним клиентом — включая внутренние аномалии, P4 (Lost Update) и G-singleаномалию изоляции, при которой в графе зависимостей транзакций образуется цикл из одной read-write и одной write-read зависимости даже в режиме serializable. При внесении сбоев появлялись новые поведения: порча или усечение WALжурнала предзаписи, в который база сначала пишет изменения, а затем применяет их к данным давали частичную потерю данных, а имитация неверно направленных чтений и записей — полную.
То есть заявленная распределённая архитектура в протестированных сборках не проверялась вовсе; проверялась одноузловая согласованность и устойчивость к сбоям на одном узле. Многоузловых сценариев в отчёте нет.
Как устроены рабочие нагрузки
Стандартные нагрузки rw и append выполняли случайно сгенерированные транзакции. rw работал с целочисленными значениями по уникальным целочисленным ключам; append — со списками целых чисел, в которые дописывались уникальные целые. Для обеих использовался транзакционный чекер Elle: он строит граф зависимостей транзакций и ищет в нём циклы, а также нециклические явления вроде G1a (Aborted Read)аномалия, при которой транзакция читает данные незавершённой или отменённой транзакции. Для append Elle дополнительно выводил version ordersпорядки версий значений ключей, по которым восстанавливается история записей.
Когда эти нагрузки выявили нарушения согласованности внутри одной транзакции, Jepsen спроектировал нагрузку internal: «When these workloads revealed consistency violations within a single transaction, we designed an internal workload to stress internal consistency». Она выполняла серию случайных транзакций с INSERT, UPDATE, DELETE и SELECT над простой таблицей ключ-значение и проверяла, что внутри каждой отдельной транзакции чтения видят предшествующие записи. Отличие от rw и append принципиальное: те проверяют зависимости между транзакциями, а internal — согласованность операций внутри одной транзакции.
Ошибки уровня SQL-семантики
В версиях 1.1.4–1.1.17 любой предикат вида WHERE something = x агрессивно трактовался как поиск по первичному ключу со значением x, независимо от левой части. Запрос SELECT * FROM t WHERE b = 5 игнорировал строку с b = 5 и возвращал строку с первичным ключом a = 5 (в примере — 5 | 6). Запрос SELECT * FROM t WHERE 4 = 5 возвращал ту же строку, хотя равенство ложно. К версии 1.1.18.4 ParticleDB «научилась» тому, что не всякое сравнение на равенство связано с первичным ключом (#18); окончательно это подтвердилось в 1.1.18.11.rc-1 (#47).
Та же механика давала странности с UNIQUE-колонкой. Внутри транзакции над таблицей t (a INT UNIQUE) с двумя строками (1 и 2) запрос WHERE a = 1 вернул строку со значением 2; WHERE a = 3 и WHERE a IN (1, 2) — тоже строку 2; WHERE FALSE — снова строку 2. При этом WHERE TRUE и WHERE 3 = 3 вернули 0 строк: предикаты, вычисляющиеся в TRUE без обращения к ключу, давали пустой результат. Результат определялся не значением предиката, а обращением к ключу.
Нарушения ограничений целостности
Ограничения NOT NULL в версиях 1.1.4–1.1.17 игнорировались. Вставка NULL в колонку BOOLEAN NOT NULL проходила успешно, и получалась «частично невидимая» строка: SELECT * возвращал 0 строк, COUNT(*) возвращал 1, а COUNT(*) WHERE a IS NULL возвращал 0 — значение NULL в этой строке не распознавалось как NULL. Ограничения null начали соблюдаться в 1.1.18.4 (#8), поведение улучшилось в 1.1.18.9 (#35).
UNIQUE-ограничения в 1.1.4–1.1.18.4 принимались, но не применялись; в 1.1.18.6 их стали применять частично. С 1.1.4 по 1.1.18.8 UPDATE мог создать несколько строк с одинаковым первичным ключом: после UPDATE t SET id = 'c' обе строки получали id c. Выборка по равенству возвращала только одну из них, а по LIKE — обе: WHERE id = 'c' давал одну строку, WHERE id LIKE 'c' — две.
Первичный ключ можно было обновить в NULL: в 1.1.4–1.1.18.11.rc-1 UPDATE t SET a = NULL оставлял в таблице строки с пустым ключом, хотя вставка NULL в первичный ключ запрещалась. Отклонять такие попытки база стала в 1.1.18.11.rc-3 (#57). Отдельный случай — DELETE и INSERT того же ключа в одной транзакции: в 1.1.18.4–1.1.18.11.rc-11 транзакция прерывалась с PDB wire error 1064: SQL execution error: duplicate key value violates unique constraint (key=7), но только через интерфейс ParticleDB, не через Postgres. Orkes частично исправил это (#82) в 1.1.18.11.rc-9 и ещё один случай — в 1.1.18.11.rc-18.
Джойны: колонки, строки, путаница
В 1.1.4–1.1.18.11.rc-1 при соединении таблиц колонка a переименовывалась в right.a, right.right.a и так далее, а имена таблиц не возвращались вовсе. Запрос SELECT * FROM t FULL OUTER JOIN u ON t.a = u.a возвращал [{:a 1, :right.a 1.0} {:a 2, :right.a 2.0}] вместо ожидаемых :t/a и :u/a. Программы, рассчитанные на обычное SQL-поведение, падали при чтении отсутствующих колонок либо молча получали null. В 1.1.18.11.rc-3 переименование прекратилось, но имена таблиц по-прежнему опускались, и коллизии имён уничтожали информацию.
Внешние соединения непустой таблицы с пустой в 1.1.4–1.1.18.11.rc-1 часто возвращали ноль строк вместо всех строк непустой таблицы (#59): SELECT t.a AS t, u.a AS u FROM t LEFT JOIN u ON u.a = t.a при двух строках в t и пустой u давал «(0 rows)». К 1.1.18.11.rc-3 это исправилось.
При соединении таблиц с одинаковыми именами колонок в 1.1.4–1.1.18.11.rc-6 СУБД путалась даже при полной квалификации (#77). Условие WHERE u.a = 'this' не должно было ничего сопоставить, но вместо этого сопоставлялась строка t с t.a = 'this' и кросс-соединялась со всеми строками u. В 1.1.4–1.1.18.9 выходные колонки как будто менялись местами. Если t.a и u.a имели разные типы, ParticleDB пыталась привести типы к совместимым формам, порождая странные ошибки или причудливые результаты. Версия 1.1.18.11.rc-7 была гораздо меньше запутана.
«Зомби»-строки: удалённые, но видимые
После DELETE внутри транзакции строка оставалась присутствующей в некотором смысле: SELECT COUNT(*) FROM t WHERE a = TRUE возвращал 1, WHERE a = FALSE — 0, то есть агрегат видел строку как имеющую a = TRUE. Прямое чтение SELECT a FROM t WHERE a = TRUE при этом возвращало 0 строк. Следы удалённых записей в агрегатах перестали воспроизводиться в 1.1.18.11.rc-1 (#45).
Отдельный класс — строки, которые нельзя удалить. После UPDATE t SET i = 1.5 для int-колонки строка в SELECT выглядела как целое 1, но была «специальной», поскольку UPDATE передал 1.5. DELETE FROM t возвращал DELETE 0, а SELECT * продолжал показывать 1. Обновление той же строки по первичному ключу тем же значением 1.5 зомби не создавало — проблема (#23) была связана с предикатами. В 1.1.18.4 поведение исчезло. Похожая проблема (#84, «Team Rocket») была у таблиц с колонкой double precision: их можно было обновлять целыми значениями, но это порождало зомби-строки, которые нельзя ни обновить, ни удалить.
Самое радикальное проявление — DROP TABLE в 1.1.18.9–1.1.18.11.rc-1 оставлял «тень» таблицы, чьи строки появлялись в новых таблицах с тем же именем и более-менее совместимой схемой (#46, Pet Sematary). После создания t (a text, b real) и вставки ('boo', 18.83) внутри транзакции, затем создания новой t (a text, b int) и SELECT * в новой транзакции возвращалась строка boo | 19 — воскрешённая строка не идентична прежней: 18.83 превратилось в целое 19. Поведение казалось связанным с транзакциями и исчезло в 1.1.18.11.rc-3.
Порча состояния: NaN, Infinity, порядок колонок
В 1.1.4–1.1.17 интерфейс Postgres неправильно сериализовал бесконечные значения: INSERT INTO t (a) VALUES ('Infinity') проходил, а SELECT * давал PSQLException: "Bad value for type double : inf". NaN сериализовался корректно, а в psql значения возвращались как inf, а не Infinity. В 1.1.18.6 бесконечные значения стали сериализоваться правильно (#15).
Отдельно NaN в 1.1.4–1.1.18.4 иногда превращался в 0.0: после вставки 2.5, затем 'NaN' выборка возвращала 2.5 и 0. Наличие порчи зависело от порядка вставки: если первым в таблицу вставлялось NaN, оно и последующие NaN сохранялись. В 1.1.18.6 NaN стал храниться правильно (#21).
INSERT в 1.1.4–1.1.17 игнорировал список колонок и использовал порядок, заданный при создании таблицы. Строка, вставленная как INSERT INTO t (b, a), физически записывалась с значениями в другом порядке и не читалась через SELECT *, хотя база считала, что в таблице две строки. При чтении по первичному ключу был виден другой порядок колонок: WHERE a = 'x' возвращал a | b со значениями x | t, а WHERE a = 'y' — b | a со значениями f | y. В общем случае любой запрос, встречавший такую строку, молча давал ноль результатов. Второй DELETE, когда в таблице оставались только строки с порядком (b, a), успешно удалял оставшуюся строку — то есть удаление затрагивало не те строки.
UPDATE по только что созданной пустой таблице возвращал UPDATE 1 и создавал строку (#24). Эту строку нельзя было увидеть напрямую по первичному ключу, но она конфликтовала со вставками: INSERT со значением (0, 100) проходил, а (10, 110) падал с ERROR: duplicate key value violates unique constraint (key=10). Внутри транзакции созданная строка читалась по первичному ключу id = 5 и печаталась так, будто у неё только одна колонка: val = 50. Такие повреждённые строки заставляли запросы, встречавшие их, молча возвращать ноль строк, даже если были другие подходящие корректные строки.
Транзакции: откаты, вложенность, агрегаты
После синтаксической ошибки транзакция становилась проваленной, но её записи сохранялись: после INSERT INTO t (a) VALUES (3) и ошибочного THIS IS NOT VALID SQL последующий COMMIT вернул ROLLBACK, однако SELECT * FROM t показал [{:a 3}]. Клиент ParticleDB на COMMIT проваленной транзакции возвращал [#:next.jdbc{:update-count 0}] вместо ошибки — транзакция якобы фиксировалась, но эффекты были невидимы. Оба интерфейса в 1.1.18.11.rc-18 стали возвращать ошибки и не показывать эффекты отменённых транзакций (#43).
Вложенный BEGIN внутри транзакции в 1.1.18.11.rc-1 выдавал предупреждение there is already a transaction in progress и сохранял записи текущей транзакции. В более раннем поведении (#44) после INSERT и повторного BEGIN запрос SELECT * возвращал 0 строк, хотя строка была вставлена.
Агрегаты иногда не видели вторую строку: после INSERT значения 3 внутри транзакции SELECT COUNT(*) показывал 1, тогда как SELECT * видел две строки (2 и 3). Агрегаты, взаимодействующие со значением столбца, например SELECT COUNT(*) FROM t WHERE a < 5, иногда наблюдали корректное состояние. В других случаях SELECT-запросы создавали дубликаты строк внутри транзакции, и каждый запрос видел всё больше строк; эти поведения (#48) исчезли в 1.1.18.11.rc-1. Дубликаты одной строки при наличии первичного ключа (#89, Double Trouble) наблюдались в 1.1.18.11.rc-9 и исчезли в 1.1.18.11.rc-18.
Аномалии изоляции без сбоев
Read Skew (A5A) — аномалия, при которой транзакция наблюдает одну часть другой транзакции, но не наблюдает другую. В тесте первая транзакция добавила 1 к ключу 112 и 29 к ключу 103; вторая увидела добавление к 112, но пропустила добавление к 103. Поскольку T1 read-write зависит от T2, а T2 write-read зависит от T1, это одновременно G-single и нарушение Snapshot Isolation. Аномалия воспроизводилась с интерфейсом ParticleDB, режимом WAL groupsync и режимом транзакций table-2pl (Strong Serializable) в версии 1.1.18.11.rc-18; к 1.1.18.11.rc-19 больше не наблюдалась (#106).
Режимы WAL задают, как журнал предзаписи сбрасывается на диск. В режиме groupsync записи журнала синхронизируются группой, а в режиме sync — синхронно; именно поэтому stale read при sync возникал реже и требовал более длительного прогона, чтобы проявиться.
Stale Read — аномалия, при которой транзакция читает устаревшее значение, нарушая требование Strong Serializability о реальном порядке транзакций. В 1.1.18.11.rc-18 она возникала даже при WAL-режиме sync, примерно раз на 300 000 транзакций при 10 клиентах и ~80 транзакциях в секунду (распределение ключей — экспоненциальное, как в Elle). Структура одинаковая: цикл из real-time, read-write и write-read рёбер — верхняя транзакция добавила 66 к ключу 22209, средняя прочитала это значение, а нижняя, начавшаяся после завершения средней, прочитала 22209 без 66. Это нарушение Strong Snapshot Isolation и Strong Serializability. Orkes сообщил, что 1.1.19-rc.10 больше не проявляет stale read (#109).
Потеря данных при сбоях и рестартах
После kill -9 и рестарта WAL-лог recover_streaming — журнал восстановления потока записей WAL — выдал предупреждение о несовпадении контрольной суммы в хвосте сегмента: checksum mismatch in segment tail, stopping read segment_id=0. Далее он сообщил, что воспроизводит только валидный префикс уже запечатанного повреждённого сегмента WAL (при наличии маркера .corrupt) и продолжает со следующими сегментами полосы, с параметрами corruption_offset=25184, valid_size=25184, recovered_entries=242 и детализацией checksum mismatch: stored 0xf3b8aa37, computed 0x4a774da2. Запечатанный сегмент — это уже закрытый для записи кусок журнала, который восстановление только читает; полоса — набор сегментов, которые журнал ведёт последовательно и восстанавливает один за другим. Сервер всё равно запустился и штатно обслуживал клиентские запросы. Чтение ключа 1754 показало, что 44 элемента исчезли — осталось лишь [:r 1754 [81]].
Ещё жёстче повёл себя узел при порче файла: стенд перемешал 13 четырёхкилобайтных кусков первого файла .wal — примерно одну десятую содержимого — и убил узел. Хотя в файле было сотни килобайт, при старте ParticleDB залогировал No data found in storage — starting fresh и начал выдавать ERROR: table not found на каждый запрос; ни один запрос после этого не завершился успешно (#108). Похоже, база удалила содержимое своего WAL-файла: через несколько секунд его размер был ноль байт, и то же самое в конце теста. Разнообразие сообщений об ошибках указывает на возможные несколько путей к потере данных. Кроме того, почти во всех тестах ParticleDB писала только WAL-файлы и хранила таблицы целиком в памяти.
Почему так вышло: процесс разработки и роль LLM
Около двух тысяч строк генеративных тестов нашли множество ошибок, и примеры обычно легко воспроизводились. Документация при ближайшем рассмотрении оказалась «полна упущений, несоответствий и выдумок»: TLA+-доказательство не опубликовано, документация Java-клиента ссылалась на несуществующие координаты зависимостей и выдумывала имена классов и пакетов, примеры быстрого старта придумывали неверные имена пользователей по умолчанию, а документация хранимых процедур сфабриковала воображаемый адрес электронной почты автора отчёта.
В разделе QA Jepsen задаётся вопросом, почему при почти каждой протестированной функции обнаруживались грубые ошибки, и предполагает структурные недостатки процесса, включая вариант, что «инженеры доверили работу по тестированию большим языковым моделям». В разделе об LLM говорится, что качество пострадало: ParticleDB была «фракталом ошибок корректности», инструменты LLM неоднократно заявляли об исправлении ошибок, которые на деле не были исправлены, а Orkes не мог объяснить причины или решения почти каждой найденной проблемы. Использование LLM, по мнению Jepsen, подрывает доверие к документации, программному обеспечению и организациям, которые их производят.
Ограничения методологии
Jepsen прямо признаёт: тесты могут доказать наличие багов, но не их отсутствие. Часть находок осталась непроверенной на финальных версиях: для #54 не было возможности протестировать версию, в которой Orkes заявляет об исправлении (1.1.19-rc.13), а #83 Orkes считает исправленной в 1.1.19-rc.12. По версии 1.0.0 тестировался только один узел за раз, поэтому о безопасности системы репликации судить нельзя.
Что из этого следует на практике
- Заявленный уровень изоляции ничего не значит без проверки.
table-2plобещал Strong Serializable, но давал Read Skew и stale read;serializableв разных версиях означал разные режимы. Прежде чем полагаться на имя режима, его нужно проверять на своих сценариях. - Ошибки SQL-семантики опаснее редких аномалий изоляции. Предикат, трактованный как поиск по ключу,
WHERE 4 = 5, возвращающий строку, илиSELECT *, не видящий вставленную строку, ломают приложение постоянно, а не раз в сотни тысяч транзакций. - «Частичная видимость» строк — худший вид порчи. Строка, которую видит
COUNT(*), но не видитSELECT *, или которую нельзя удалить, означает, что приложение и база расходятся в представлении о содержимом таблицы без единой ошибки. - Durability не гарантирована при порче WAL. Узел, стартующий с
No data found in storageповерх непустого файла и затем удаляющий его содержимое, теряет подтверждённые записи молча — без отказа стартовать. - Репликация осталась непроверенной. Все выводы относятся к одиночному узлу; о безопасности Raft-кластера по этому отчёту судить нельзя.
- Документация как источник истины ненадёжна. Если она ссылается на несуществующие зависимости и выдумывает имена классов, проверять поведение приходится экспериментально.