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` с новой версией схемы
