Skip to content
Snippets Groups Projects

status: accepted

decision-makers: @kostja, @darthunix, @gmoshkin

consulted: @Gerold103


TRUNCATE нужно считать DDL-запросом и исполнять через стандартный алгоритм DDL с применением map_callrw

Описание запроса

  • TRUNCATE t -- это SQL-запрос, который очищает всё содержимое таблицы (по сути DELETE FROM t)
  • Для исполнения TRUNCATE-запроса на стороджах необходимо вызвать метод box.space.truncate
  • Он не вызывает on_replace тригеры Тарантула (в отличие от INSERT, UPDATE и DELETE)
  • Тарантул относит этот запрос к категории schema-change (DDL), а не data-change (DML) (см. документацию SQL), хотя в нашем форке исполнение операции номер схемы не меняет. Нужно иметь в виду, что локальный TRUNCATE Тарантула -- это операция, к которой в рамках транзакции нельзя применить rollback
  • При вызове на сторадже функции box.space.truncate, она исполняется в фоне, не блокируя последующие вызовы (см. документацию space-api)
  • Для исполнения TRUNCATE на таблице t пользователю необходимо получить привилегию WRITE (в будущем её стоит заменить на отдельную привилегию TRUNCATE)

Продуктовые требования к распределённому TRUNCATE

Исходная задача на GitLab.

Ниже представлен список продуктовых требований к реализации TRUNCATE:

  • TRUNCATE должен корректно исполняться с работающим ребалансером
  • TRUNCATE должен быть линеаризован с другими DDL
  • За счёт проверки привилегий TRUNCATE должен быть линеаризован с GRANT/REVOKE
  • TRUNCATE должен либо полностью применяться, либо полностью не применяться. Нельзя удалить часть данных и вернуть ошибку. Ошибку можно вернуть, только если мы гаранитрованно не удалим ничего или удалим всё
  • TRUNCATE не должен блокировать изменения топологии. Если TRUNCATE застрял из-за того, что часть узлов не могут выполнить какой-то из его шагов, то должна быть возможность удалить эти узлы из кластера или иначе их починить (например, узлы опущены и надо их просто поднять) так, чтобы TRUNCATE завершился успешно. В идеале TRUNCATE не должен ждать только применения на всех мастерах репликасетов, и не блокироваться о недоступные read-only реплики

Пожелания:

  • TRUNCATE не должен блокировать DDL-запросы к другим таблицам

Ребалансировка

Необходимо избежать следующего аномального сценария с ребалансировкой:

  1. Начинается vshard-ребалансировка бакетов. Данные с одних инстансов начинают переезжать на другие (инстанс удаляет у себя данные таблицы и отправляет запрос, пересылающий эти данные на другой инстанс)
  2. Исполнение TRUNCATE t начинается на узле-координаторе
  3. До вершин кластера доходит TRUNCATE t, затирая все данные
  4. До таблиц на инстансах доходят данные, переехавшие из-за ребалансинга
  5. Получается, что TRUNCATE исполнился, а в таблицах всё равно остались данные

Линеаризация с остальными DDL

Необходимо избежать следующей ABA-проблемы:

  1. Исполняется CREATE TABLE t
  2. Таблица наполняется данными (через INSERT)
  3. Исполнение TRUNCATE t начинается на узле-координаторе
  4. Исполняется DROP TABLE t
  5. Ещё раз исполняется CREATE TABLE t
  6. Ещё раз таблица наполняется данными (через INSERT)
  7. До вершин кластера доходит TRUNCATE t, которая исполняется на новой таблице

исполнение TRUNCATE должно упасть из-за того, что версия новой таблицы не совпадает с версией таблицы, над которой необходимо исполнить TRUNCATE.

Нынешний механизм исполнения DDL и DML-запросов

Исполнение DDL-запроса добавляет запись (CaS-опкод) в рафт-журнал и увеличивает номер схемы, консистентно меняя состояние всего кластера. Общий сценарий работы с рафт-журналом и обработки опкода сейчас такой:

  • Добавляется DdlPrepare-опкод в рафт-журнал на лидере
  • raft-main-loop (advance -> handle_committed_entries -> handle_committed_normal_entry) на всех узлах кластера обрабатывает DdlPrepare. Выполняет специфичную для опкода работу (например, для CreateTable создаёт записи в _pico_table и _pico_index) и создаёт запись в _pico_property с PendingSchemaChange (это значит, что операция начала применяться)
  • Губернатор, просыпаясь в очередной раз, видит PendingSchemaChange, создаёт Plan::ApplySchemaChange с указанием всех мастеров репликасетов. Через proc_apply_schema_change он посылает всем мастерам запрос на применение этого изменения
  • Мастера, исполняя apply_schema_change, выполняют специфичную для опкода работу (например, для CreateTable создаёт таблицу и primary key в локальных таблицах _space и _index) и через set_local_schema_version обновляют локальную версию схемы до PendingSchemaVersion (в системной таблице Тарантула _schema мы храним своё значение с ключом "local_schema_version")
  • Получив положительные ответы со всех мастеров, говернор добавляет в raft-журнал DdlCommit-опкод. В противном случае добавляет DdlAbort-опкод. Стоит обратить внимание, что в случае получения OnError::Retry(e) (например, по таймауту) говернор будет в бесконечном цикле пытаться заново исполнить сетевые вызовы.
  • raft-main-loop на всех узлах обрабатывает DdlCommit/DdlAbort
    • Для DdlCommit исполняет apply_schema_change (в случае если этот мастер отстал от рафт-лидера), выставляет флаг is_operable в true (предполагается, что Dml-запросы к таблице с невыставленным флагом не будут обрабатываться) и удаляет PendingSchemaChange
    • Для DdlAbort исполняет ddl_abort_on_master (например, для CreateTable подчищает записи в локальных таблицах _space и _index)

Логика исполнения DML-запроса зависит от того, с какой таблицей мы работаем:

  • Запрос над глобальными пользовательскими таблицами выполняется через raft-журнал в виде CaS DML-опкодов (так же консистентно меняя состояние кластера, как в случае с DDL-запросами).
  • Для шардированных таблиц выполняется vshard-функция replicaset::callrw. Сейчас идёт переход на новую, более консистентную функцию map_callrw, поэтому будем дальше ссылаться на неё. В эту функцию передаётся сгенерированный ExecutionPlan, который на стороджах трансформируется в транзакционный вызов функций space-api либо в исполнение локального SQL.
    • DML-запросы над таблицей t с узла-координатора на стораджи отправляются с указанием table_version, которая была прочитана на координаторе на момент вызова map_callrw. Любой DDL-запрос, затрагивающий таблицу t, после успешного исполнения увеличивает table_version этой таблицы (в системной таблице _pico_table на каждом инстансе). В случае, если между отправкой DML-запроса с узла-координатора и исполнением этого запроса на стороджах над этой таблицей был исполнен DDL-запрос (мы заметим, что отправленная через map_callrw версия таблицы отличается от той, что мы прочитали локально), DML-запрос исполнен не будет и мы получим ошибку.
    • Функция map_callrw до исполнения пишушего запроса на стораджах рефает (ref) все затрагиваемые стороджа. См. комментарии к функции. Делает она это для того, чтобы убедиться в отсутствии ребалансировки во время пишущего запроса. Иначе мы бы могли столкнуться с НЕприменением пишущих запросов к данным таблиц переезжающих с одного стораджа на другой.

Скажем сразу, чем при описанном механизме исполнения хорошо считать TRUNCATE -- DDL-запросом:

  • мы будем отбивать часть DML-запросов. Таких, для которых между отправкой с узла-координатора до момента исполнения на сторадже уже успел исполниться TRUNCATE t. Или в целом все запросы на таблицу t, если учесть, что мы собираемся проверять is_operable флаг
  • отбивать все остальные DDL-запросы к этой таблице до тех пор, пока is_operable не будет выставлен в true

Связанные задачи

Реализация TRUNCATE, удовлетворяющая описанным выше требованиям, затрагивает другие сейчас незакрытые задачи:

  1. Неконсистентное исполнение DDL-операций при работающем ребалансировщике. См. тикет
  2. Неконсистентное исполнение через вызов map_callrw для DML-запросов на шардированных таблицах. Запрос может провалиться на части из узлов (например, по таймауту при разрыве сети), и мы с этим ничего не делаем. Эту проблему нужно будет решить в том случае, если мы решим реалзивывать TRUNCATE как DML-операцию

Выбранное решение

В качестве решения предлагается считать TRUNCATE DDL-запросом и пускать исполнение TRUNCATE по описанному выше алгоритму DDL, но на этапе обработки Plan::ApplySchemaChange на гаверноре вызывать proc_apply_schema_change не напрямую через rpc, а через map_callrw. Таким образом мы избежим необходимости отдельно чинить ребалансировку бакетов для DDL, потому что map_callrw уже поддерживает ref стораджей (pin бакетов). Механимзм рафт-журнала обеспечит нам автоматическое применение TRUNCATE на упавших узлах, которые вовремя не получили запрос (то есть во время исполнения TRUNCATE может и не исполниться на всех узлах, но зато исполнится в момент восстановления узла кластера при чтении рафт-журнала лидера). Алгоритм целиком будет выгледеть так:

  • Добавить DdlPrepare-опкод в рафт-журнал на лидере
  • При обработке DdlPrepare создать PendingSchemaChange и выставить для таблички флаг is_operable в false
  • На губернаторе при обработке Plan::ApplySchemaChange через map_callrw вызвать proc_apply_schema_change для применения на всех узлах box.space.truncate. Повторять запрос до тех пор, пока он не удастся. После успешного исполнения map_callrw добавить DdlCommit-опкод в рафт-журнал
  • При обработке DdlCommit выставить флаг is_operable в true и удалить PendingSchemaChange

Данное решение требует внесения наименьшего количества изменений и использует уже реализованные алгоритмы. Из-за работы с PendingSchemaChange исполнение TRUNCATE t будет блокировать остальные DDL-запросы, даже не связанные с таблицей t. Однофазное исполнение будет более оптимальным решением, но пока что было решено отложить эту оптимизацию.

Альтернативные (отвергнутые) варианты решения

  1. Считать его DML-запросом и исполнять через map_callrw. Функция сама останавливает ребалансировку, не блокирует другие DDL-запросы, но может исполниться неконсистентно (см. проблему выше). Единственное, что мы можем предпринять в случае получения ошибки с одного из мастеров -- это бесконечно повторять исполнение до тех пор, пока оно не удастся на всех мастерах, что кажется плохим решением

  2. Считать его DDL-запросом, исполнять по схеме DDL, починить исполнение DDL при работающем ребалансировщике (см. секцию ниже). Нужно будет добавить специфичную для TRUNCATE логику в процесс обработки DDL:

    • На этапе DdlPrepare выставить флаг is_operable в false
    • На этапе proc_apply_schema_change проверять, что таблица существует
    • На этапе DdlCommit исполнять box.space.truncate (добавить эту логику в apply_schema_change в случае выставленного флага is_commit)
    • На этапе DdlAbort исполнять box.space.truncate
  3. Реализовать исполнение TRUNCATE через одну запись в рафт-журнал:

    1. Записать TRUNCATE в рафт-журнал. В этой же транзакции повысить версию схемы
    2. Во время apply исполнить box.space.truncate

    В момент исполнения TRUNCATE или DROP TABLE мы уверены в том, что не получим Abort ответ, потому что подобный ответ мы можем получить только в случае отсутствия таблицы, над которой работаем (а raft гарантирует, что этого не произойдёт, потому что все операции линеаризованы). В таком варианте мы не будем блокировать остальные DDL-запросы из-за установленного PendingSchemaChange, но придётся реализовать новый механизм исполнения. Было предложено отложить реализацию этого алгоритма.

DDL и ребалансировка

Проблему с DDL и ребалансировкой предлагается решить следующим способом.

  1. Перед исполнением любой DDL-операции вручную останавливать ребалансинг, дёргая ручку vshard.storage.rebalancer_disable на каждом из мастеров. Например, при обработке ApplySchemaChange добавить второй цикл, который пробежиться по всем мастерам и дёрнет на них эту ручку. А при завершении обработки позвать vshard.storage.rebalancer_enable
  2. Пропатчить vshard таким образом, чтобы при ребалансировке (см., например, bucket_send_xc и bucket_recv_xc в vshard/storage/init.lua) отправлять помимо данных таблиц ещё и номер схемы (наш local_schema_change), прочитанный на узле-отправителе в момент вызова replicaset:callrw. В случае, если номер схемы на получателе оказался выше номера схемы на отправителе (то есть за это время, например, успел исполниться какой-то DDL-запрос) предлагается останавливать процесс ребалансировки.
    • rebalancer_worker_f -- это тело файберов, которые запускаются для ребалансировки. Все они делят так называемый dispenser, который представляет собой контейнер путей между узлами, по которым необходимо совершить ребалансировку. rebalancer_worker_f крутится в цикле до тех пор, пока dispenser выдаёт пути для ребалансировки
    • При вызове bucket_send в случае возникновения ошибки НЕShardingError или ошибки ShardingError кроме TOO_MANY_RECEIVING (воркер заснёт в качестве троттлинга) путь из dispenser удаляется, потому что считается, что кластер поломан. В случае иной ошибки, путь кладётся обратно в dispenser. Кажется, что наподобие TOO_MANY_RECEIVING можно добавить ещё и ошибку вроде OLD_SCHEMA_VERSION, которая так же положит путь обратно в dispenser, чтобы он потом вызвал bucket_send с новой версией схемы