В рамках задачи предлагается добавить SQL-команду BACKUP.

Команда предназначена для создания резервной копии **кластера** узлов Picodata.

В рамках данного ADR термины "резервная копия" и "бэкап" используются как взаимозаменяемые.

## Конфигурация

Для создания резервной копии каждого инстанса, необходимо понимать, в какую директорию резервировать его данные. Для этого в рамках задачи предлагается добавить новую настройку:
* Аргумент коммандной строки -- backup-dir
* Аналогичную переменную окружения PICODATA_BACKUP_DIR
* Аналогичный параметр в файле конфигурации instance.backup_dir

По умолчанию это значение будет равно "{instance.instance_dir}/backup".

## Описание

Вызов команды BACKUP создаёт новую локальную резервную копию на каждом инстансе. При исполнении команды в директории instance.backup_dir создаётся папка, содержащая следующие файлы инстанса:
* .snap файлы и файлы, спецфичные для vinyl (.vylog, .index, .run)
* файл конфигурации (PICODATA_CONFIG_FILE)
* файл .picodata-cookie (PICODATA_SERVICE_PASSWORD_FILE)
* файлы плагинов (хранящиеся в instance.share_dir)

Уточнение: для восстановления на конкретную точку времени помимо .snap нужно сохранять ещё и .xlog файлы. В рамках этой задачи подобную функциональность мы не реализуем

После успешного завершения команда BACKUP возвращает список узлов и пути к файлам бэкапов каждого инстанса. Результат представляется в виде json следующего вида:
```
{
    "server1_address": {
        "instance1_name": "backup_path_1",
        "instance2_name": "backup_path_2"
    },
    "server2_address": {
        "instance4_name": ...,
        "instance5_name": ...
    }
}
```
, где
* server_address   = объединение инстансов по instance.iproto_advertise
* instance_name    = instance.name
* backup_path      = полный путь до instance.backup_dir

Резервные копии специально отвязаны от IP-адресов инстансов, чтобы их можно было разворачивать на любых серверах.

В качестве альтернативного варианта, BACKUP может возвращать только имя папки локальных бэкапов (оно одинаковое для всех инстансов).


## Удаление предыдущих резервных копий

В случае, если создаётся большое количество резервных копий, они начинают занимать много пользовательской памяти. Ответственность за удаление предыдущих ненужных резервных копий берёт на себя администратор СУБД.

## Полезные ссылки

* Tarantool [backup](https://www.tarantool.io/en/doc/latest/admin/backups/)
* Tarantool [snapshot](https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_snapshot/)

## Описание алгоритма

Предлагается реализовать BACKUP как DDL-операцию.

### Имена папок с локальными резервными копиями
Для удобства восстановления имена папок для одной резервной копии на всех инстансах кластера должны быть одинаковыми, поэтому мы генерируем это имя на говерноре. Имена папок предлагается генерировать в виде YYYYMMDDThhmmss (см. [wiki](https://ru.wikipedia.org/wiki/ISO_8601) с описанием форматов): такие имена будут отсортированы в порядке создания бэкапов и их можно будет вбивать в консоль руками. При генерации подобных имён для нескольких бэкапов может возникнуть коллизия в случае смены рафт-лидера на узел с отстающим временем (может быть снят timestamp, который будет равен timestamp предыдущей резервной копии). Для избежаний коллизий генерировать имена будем по следующему алгоритму:
* На гаверноре прочитаем текущий timestamp (cur_timestamp)
* Если в _pico_property нет записи с ключём "last_backup_timestamp" (LastBackupTimestamp), создать её со значением cur_timestamp
* Если в _pico_property уже лежит запись с данным ключём и значением prev_timestamp, сгенерировать значение new_timestamp = max(cur_timestamp, prev_timestamp + 1) и сделать его новым ключём "last_backup_timestamp"
* На основании "last_backup_timestamp" сгенерировать строковое имя в нужном формате

В процессе создания резервной копии помимо прочего в сохранённые файлы попадёт и табличка _pico_property с ключём "last_backup_timestamp". После восстановления по этому ключу можно будет узнать, в какой момент был сделан бэкап, из которого мы восстановились.

### Копирование файлов при локальной резервной копии

При создании бэкапа копировать файлы в новую директорию -- это дорого. Если папка для резервной копии и сами файлы находятся в одной файловой системе, вместо копирования этих файлов можно создать на них hard-link'и. Если создать hard-link'и не получится, файлы нужно будет копировать через неблокирующий тарантульный евент-луп ввод-вывод. Cм. copytree (https://www.tarantool.io/en/doc/latest/reference/reference_lua/fio/#fio-copytree) -- это луашная функция, которая неблокирующе рекурсивно копирует файлы в директории (используя для этого тарантульную машинерию для взаимодействия с async io). Вместо того, чтобы делать биндинги на сишные функции, реализующие async io, предлагается реализовать copytree с помощью coio_call (см. https://docs.rs/tarantool/latest/tarantool/coio/fn.coio_call.html). Эта функция позволяет выполнить блокирующий код так, чтобы он не блокировал текущий файбер. Предлагается в неё передавать блокирующий код, рекурсивно копирующий директорию.

### Алгоритм исполнения локального BACKUP

Предлагается добавить новую хранимую процедуру proc_backup, которая будет реализовывать логику создания резервной копии на одном инстансе. Делать она будет следующее:
* проверить, что инстанс содержит файл конфигурации. В случае, если это не так, выдать предупреждение о том, что файла конфигурации нет: при запуске инстанса пользователь мог указать важные настройки вроде memtx-memory или init-replication-factor, которые без файла конфигурации не будут отражены в бэкапе
* синхронизировать raft-журнал до состояния рафт-лидера (вызовом `Node::wait_index`), где индекс лидера передаётся как аргумент хранимой процедуры. См. `proc_apply_schema_change`, чтобы понять, какую дополнительную логику стоит исполнить на узлах (например, исполнить ранний возврат из функции, если мы видим, что `local_schema_version >= pending_schema_version`)
* вызвать `box.backup.start` -- он вернёт список файлов (как уже созданных, так и тех, которые появятся на следующем шаге), которые относятся к резервной копии таблиц и которые нужно будет сохранить
* вызвать `box.cfg{ read_only = true }`
* вызвать `box.snapshot`
* вызывать `box.cfg{ read_only = false }`
* в instance.backup_dir создать директорию с именем, сгенерированном на основании "last_backup_timestamp"
* если оказывается, что такая директория уже существует -- это означает, что два инстанса, запущенных на одном и том же сервере, указали одну и ту же директорию для сохранения резервных копий. Нужно бросить ошибку `BackupError::DuplicateDir`, которая приведёт к порождению DdlAbort-опкода
* скопировать необходимые файлы в созданную директорию
* увеличить `"local_schema_version"` до той, которая была указана в _pico_property
* в качестве результата вернуть полный путь до папки, в которую была сохранена резервная копия инстанса

#### Ошибки
Может ли возникнуть ошибка на каком-то этапе снятия локальной резервной копии (в том числе retriable)? Если retriable ошибки возможны, то нужно в начале исполнения удалить директорию бэкапа, которая могла быть создана ранее, и вызвать `box.backup.stop`.

### Алгоритм исполнения кластерного BACKUP

#### Базовая реализация и оптимизации

Оптимальный алгоритм создания кластерного бэкапа содержит много подводных камней и требует много времени для реализации. Предлагается реализовывать BACKUP постепенно от самого банального и медленного решения в лоб до оптимизированного. Ниже описаны несколько соображений на этот счёт:
* Сначала поддерживается memtx, а потом vinyl
* Сначала локальный бэкап создаётся на всех узлах кластера (на мастерах и фолловерах). Этот вариант проще для алгоритма восстановления из резервной копии, потому что в противном случае нам бы пришлось копировать файлы-снэпшоты с матера на фолловеры, а потом править на них системную таблицу Тарантула _cluster и значения uuid в системных таблицах Пикодаты. В качестве оптимизации потом предлагается создавать снэпшоты только на мастерах
* При исправлении полей системных таблиц (см. ниже алгоритм восстановления) нужно будет запустить команду `picodata restore`. Она применит снэпшот, без запуска кластерной логики изменит значения локальных системных табличек и затем перезапишет получившийся снэпшот. Это медленно, потому потом предлагается написать код, который будет редактировать сырой .snap файл без необходимости его применения (можно вынести эту логику в тарантул-модуль или прямо в наш форк при инициализации кластера из резервной копии)
* Сейчас идёт переезд таблицы _bucket (логика vshard) в системные таблицы Пикодаты, поэтому логика остановки ребалансировщика через map_callrw (см. ниже) может быть избыточно

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

#### Основной алгоритм

1. Если видим, что часть инстансов сейчас неактивны -- не начинаем исполнять BACKUP и выдаём ошибку, чтобы не зависать на нём
2. Отключить failover (TODO: как?)
3. Добавить DdlPrepare-опкод в рафт-журнал на лидере
4. При обработке DdlPrepare
   1. добавить запись PendingSchemaChange в _pico_property
   2. добавить запись LastBackupTimestamp в _pico_property
5. На губернаторе при обработке Plan::ApplySchemaChange:
   1. сгенерировать имя папки для новой резервной копии на основании LastBackupTimestamp
   2. через map_callrw вызвать proc_backup на всех мастерах репликасетов
   3. позвать proc_backup на всех репликах
   4. Повторять запрос до тех пор, пока он не удастся. После успешного исполнения map_callrw добавить DdlCommit-опкод в рафт-журнал. В итоге на говерноре мы получаем искомый список узлов и пути к резервным копиям всего каждого инстанса. Мы группируем их по instance.iproto_advertise и в виде массива складываем в _pico_property с именем "backup_paths"
6. При обработке DdlCommit удалить PendingSchemaChange и вызвать `box.backup.stop`. При обработке DdlAbort удалить PendingSchemaChange, вызвать `box.backup.stop`, удалить запись из _pico_property и удалить (вероятно созданные на некоторых мастерах) папки с локальными бэкапами
7. На узле-координаторе после вызова `wait_for_ddl_commit` достать запись из _pico_property и вернуть пользователю в виде json, описанного выше

## Восстановление из резервной копии

### Вводная информация

Перед началом процесса восстановления необходимо удалить старые файлы со всех инстансов, после чего заменить их файлами, сохранёнными в резервной копии. Поскольку это опасная операция, перед этим стоит:
* переспросить пару раз или
* заставить пользователя самого удалить файлы.

Иногда бывает нужно восстановить кластер на других серверах с той же топологией. Например для проверки восстановления из бэкапов или для копии стенда.

Предполагается, что данные будут восстанавливаться только на мастерах репликасетов. Реплики будут подтягивать данные самостоятельно.

Во время создания резервной копии в сохраняемые .snap файлы в том числе попадает информация, хранящаяся в системных таблицах и специфичная для кластера, с которого эта резервная копия была снята. Во время восстановления некоторые из этих полей необходимо будет исправить. Ниже описаны данные системные таблицы, и кластер-специфичные данные, которые они хранят:
* _pico_peer_address
  * `address`. В том числе сетевой IP-адрес инстанса и адрес для подключения по postgres-протоколу
* _pico_instance
  * `uuid`
  * `replicaset_uuid`
  * `picodata_version`
* _pico_property
  * `cluster_version`
  * `system_catalog_version`
  * `pending_schema_change` (и `pending_schema_version`) -- на момент сохранения .snap файлов, они будут содержать эту запись для текущего исполняемого BACKUP
  * `pending_plugin_operation`?
* _pico_replicaset
  * `uuid`
* _pico_plugin/_pico_service/_pico_service_route/_pico_plugin_migration/_pico_plugin_config
  * ???
* _raft_state (и _raft_log)
  * `applied`/`commit` (но с учётом того, что мы синхронизируем состояния журналов, эти значения на всех узлах должны быть одинаковыми)

### Описание алгоритма

Предлагается обязанности по восстановлению из резервной копии переложить на ansible роль. Она должна будет:
* остановить все запущенные инстансы
* удалить предыдущие данные
* исправить поля системных табличек
* загрузить новые данные

Для исправления полей системных табличек на инстансах предлагается добавить новую команду `picodata restore`. Предлагается реализовать её по аналогии с командой `picodata run` до момента вызова `init_common` (которая инициализирует Тарантул, дёргая `box.cfg`) включительно (без запуска кластерной логики вроде говернора). После того, как инстанс будет запущен, нужно применить файл-снэпшот и удалить предыдущие значения, мешающие восстановлению:
* Удалить запись PendingSchemaChange из _pico_property
* Исправить *_version поля

После исправления полей таблиц, нужно перезаписать существующий файл-снэпшот.

### Восстановление на других серверах с той же топологией

Для восстановления на других серверах команде `picodata restore` понадобиться знать два отображения вида instance_uuid -> IP:
1. которое было актуально на момент снятия бэкапа
2. которое хочется накатить при восстановлении

В таком случае предлагается передавать команде два аргумента: topology_old.yml и topology_new.yml, которые и будут представлять собой эти два отображения. Логику генерации (дампа) topology_old.yml нужно будет встроить в процесс создания резервной копии.

На основании этих двух отображений нужно пробежаться по _pico_peer_address и исправить там IP-адреса.

## Привилегии
На начальном этапе реализации предлагается дать возможность исполнять операцию BACKUP только роли admin. Иначе пользователь без привилегий сможет, например, бесконечно генерировать резервные копии и тормозить весь кластер.

У CockroachDB, например, для исполнения BACKUP и RESTORE есть отдельные привилегии. См. [документацию](https://www.cockroachlabs.com/docs/stable/backup#required-privileges).

## Тестирование

Для тестирования предлагается использовать кластер из 4 инстансов с параметром init_replication_factor = 2. Все тесты предполагают, что изначально мы наполняем данными одну шардированную и одну глобальную пользовательскую таблицу, где данные шардированной таблицы окажутся частично на первом и частично на втором репликасетах.

Для восстановления из резервной копии в тестовом фреймворке на Python необходимо продублировать логику, которую предлагается реализовать в ansible-роли.

### Инъекции ошибок

Ниже описаны инъекции ошибок, которые нужно добавить для исполнения тестов:
* BLOCK_BEFORE_BOX_SNAPSHOT -- зависнуть до вызова `box.snapshot`
* BLOCK_AFTER_BOX_SNAPSHOT  -- зависнуть после вызова `box.snapshot`, но до создания копии данных

### Тесты

Ниже описаны сценарии тестировании функциональности BACKUP/RESTORE:

#### Базовый тест
1. Создаём данные1
2. Выполняем BACKUP
3. Создаём непересекающиеся данные2
4. Выполняем RESTORE
5. Проверяем, что данные1 восстановились и что данные2 удалились

#### Double round-trip
1. Делаем бэкап
2. Восстанавливаемся из него
3. Проверяем, что база соответствует той, с которой мы снимали бэкап
4. Делаем бэкап с бэкапа
5. Восстанавливаемся из него
6. Проверяем, что база соответствует той, с которой мы снимали второй бэкап

#### Consistency check
1. Запоминаем текущий рафт-индекс
2. Делаем бэкап
3. Восстанавливаемся из него
4. Проверяем, что на всех инстансах рафт-индекс одинаковый

#### (Не)доступность во время исполнения BACKUP
1. Выставляем BLOCK_BEFORE_BOX_SNAPSHOT
2. Запускаем бэкап
3. Проверяем, что запрос на чтение таблиц проходит
4. Проверяем, что запрос на запись в глобальную таблицу проходит
5. Проверяем, что запрос на запись в шардированную таблицу данных (не пересекающихся с изначальными) проходит
6. Отпускаем BLOCK_BEFORE_BOX_SNAPSHOT
7. Восстанавливаемся из бэкапа
8. Проверяем, что бэкап содержит как изначальные данные, так и новые

#### Проверка привилегий
1. Создаём юзера
2. Из-под него исполняем BACKUP
3. Получаем ошибку привилегий

## Дальнейшая работа и возможные улучшения

* Иногда бывает необходимо, чтобы резервная копия всех файлов в папке была представлена симметрично зашифрованным архивом. Можно вынести необходимость шифрования резервных копий в дополнительную настройку
* Сохранять .xlog файлы (см. раздел Описание)
* Поддержать возможность создания инкрементальных бэкапов (кажется, не актуально для резидентных СУБД)
* Поддержать возможность восстанавливать не всю базу, а только опеределённые таблички. Например, как у CockroachDB (cм. [документацию](https://www.cockroachlabs.com/docs/stable/restore#tables))
* Добавить SQL-команду `RESTORE` (как, например, это [сделано](https://www.cockroachlabs.com/docs/stable/restore) у CockroachDB).
