Skip to content
Snippets Groups Projects
Commit 959411b7 authored by EmirVildanov's avatar EmirVildanov
Browse files

adr: TRUNCATE sql command

this adr is a proposal of implementing TRUNCATE sql command, its context and possible problems
parent 86584dc9
No related branches found
No related tags found
1 merge request!1439adr: TRUNCATE sql command
Pipeline #58591 failed
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](https://www.tarantool.io/en/doc/latest/reference/reference_sql/sql_statements_and_clauses/#truncate)),
хотя в нашем форке исполнение операции номер схемы не меняет. Нужно иметь в
виду, что локальный TRUNCATE Тарантула -- это операция, к которой в рамках
транзакции нельзя применить rollback
* При вызове на сторадже функции `box.space.truncate`, она исполняется в фоне,
не блокируя последующие вызовы (см. [документацию
space-api](https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/truncate/#lua-function.space_object.truncate))
* Для исполнения TRUNCATE на таблице t пользователю необходимо получить
привилегию WRITE (в будущем её стоит заменить на отдельную привилегию
TRUNCATE)
## Продуктовые требования к распределённому TRUNCATE
Исходная
[задача](https://git.picodata.io/picodata/picodata/picodata/-/issues/927) на 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) все затрагиваемые стороджа. См.
[комментарии](https://github.com/tarantool/vshard/blob/60a7089eb4a4ddcb33d33895811d6662f8ace353/vshard/router/init.lua#L1119)
к функции. Делает она это для того, чтобы убедиться в отсутствии
ребалансировки во время пишущего запроса. Иначе мы бы могли столкнуться с
**НЕ**применением пишущих запросов к данным таблиц переезжающих с одного
стораджа на другой.
Скажем сразу, чем при описанном механизме исполнения хорошо считать TRUNCATE -- DDL-запросом:
* мы будем отбивать часть DML-запросов. Таких, для которых между отправкой с
узла-координатора до момента исполнения на сторадже уже успел исполниться
TRUNCATE t. Или в целом все запросы на таблицу t, если учесть, что мы
собираемся проверять `is_operable` флаг
* отбивать все остальные DDL-запросы к этой таблице до тех пор, пока
`is_operable` не будет выставлен в true
## Связанные задачи
Реализация TRUNCATE, удовлетворяющая описанным выше требованиям, затрагивает
другие сейчас незакрытые задачи:
1. Неконсистентное исполнение DDL-операций при работающем ребалансировщике. См.
[тикет](https://git.picodata.io/core/picodata/-/issues/1091)
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` с новой версией схемы
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment