From 959411b7222eeb90943500e7696a7e24f7c77372 Mon Sep 17 00:00:00 2001
From: EmirVildanov <reddog201030@gmail.com>
Date: Thu, 14 Nov 2024 12:25:05 +0300
Subject: [PATCH] adr: TRUNCATE sql command

this adr is a proposal of implementing TRUNCATE sql command, its context and possible problems
---
 doc/adr/14-11-2024-truncate.md | 239 +++++++++++++++++++++++++++++++++
 1 file changed, 239 insertions(+)
 create mode 100644 doc/adr/14-11-2024-truncate.md

diff --git a/doc/adr/14-11-2024-truncate.md b/doc/adr/14-11-2024-truncate.md
new file mode 100644
index 0000000000..ee2cf59ba4
--- /dev/null
+++ b/doc/adr/14-11-2024-truncate.md
@@ -0,0 +1,239 @@
+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` с новой версией схемы
-- 
GitLab