Skip to content
Snippets Groups Projects
versioning.md 16.8 KiB
Newer Older
Sergey V's avatar
Sergey V committed
## Picodata App Versioning

2021-12-15 Sergey V

### Disclaimer

Примеры кода это псевдокод. Naming требует отдельной проработки.

### Версионирование

Давайте для начала разберемся, что от чего зависит в контексте версионирования приложений Picodata.

Завимость сверху слева, зависимый снизу справа:

```
Schema version──┐
    └─data? ────┤
                ├──rpc handlers─────┐
                └──background jobs──┤
                                    └──app version
```

У персистентного приложения всегда есть зависимость в виде схемы. Также может быть зависимость в виде данных (например, справочник категорий товаров на сайте интернет магазина).

Само приложение - это набор rpc хэндлеров и код, который выполняется в фоне.

По нашему соглашению схема в Picodata глобальная и eventually одинаковая на всех узлах.

* Если изменениями схемы считать императивные команды вроде ALTER TABLE, то версия схемы вследствие своей глобальности изменяется только вперед, от меньшей к большей (восстановление из бэкапов сейчас не рассматриваем)
* Код приложения на разных узлах локальный, может и будет отличаться при обновлениях и откатах. Версия кода меняется вперед и назад. Например, выкатили обновление с v1.0 на v1.1, потом решили откатить.

При откате приложения на старую версию схема остается новой версии.

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

### Был рассмотрен Костин вариант:

<https://gitlab.com/picodata/picodata/picodata/-/blob/f31698c7e805e5512e16138b6cf893970201aec9/docs/app-main-design.md#костина-версия>

#### Критика

Пример приложения:

```python
# this function was initially created in v1.0
fn magic_number() {
    if random() > 0.1 {
        return 42
    }
    
    ##########################################
    # these lines were added with version 2.0
    if random() > 0.5 {
        return 7
    }
    ##########################################

    return 0
}


fn main() {
    version("1.0")
    endpoint("magic_number", magic_number)
    background_job("job1", some_job)

    version("2.0")
    endpoint("magic_number_plus1", fn() { return magic_number() + 1 })
}
```

* Код и endpoints прошлых версий могут измениться вследствие работы над кодом новой версии. 
* После выкатки этого кода даже если вызов version(2.0) никогда не вернется (не соберет majority в кластере), endpoint версии 1.0 уже будет работать иначе. Это нежелательное поведение. Такого рода изменения могут быть зарыты  глубоко в библиотечном коде и явно не прослеживаться.
* Другая проблема -- обновление кода существующих endpoints и background jobs. Если нужно изменить код эндпоинта `magic_number`, то как это сделать? Изменить внутри блока version 1.0? Или же переопределить endpoint ниже в блоке с новой версией? А в старом месте определения нужно удалить или закомментировать строчку? Если удалить - то мы как-бы изменили прошлую версию, и при запуске на чистом кластере поведение будет отличаться от поведения в существующем кластере с данными. Если оставить, то у нас в проекте появится dead code и копипаст.
* **В Костиной концепции у схемы и приложения одна версия**. Но что произойдет, если при очередном обновлении потребуется откатиться? Выкатили старую версию, но схема осталась новая. Приложение может быть фактически и даже номинально несовместимо с новой схемой, например, если запускаем приложение v2.0, а схема осталась от приложения v3.0. Приложение все равно попытается запуститься и может испортить данные. Даже если создать механизмы синхронизации, которые не дадут одновременно запустить разные версии приложения, проблема останется.

Для дальнейшей проработки этого варианта от Кости требуется больший уровень вовлеченности в процесс разработки концепции версионирования. Возможно, всё просто и круто, я протсто не понял. Открытые вопросы:
  * https://gitlab.com/picodata/picodata/picodata/-/merge_requests/1#note_776679769
  * https://gitlab.com/picodata/picodata/picodata/-/merge_requests/1#note_773605140
  * https://gitlab.com/picodata/picodata/picodata/-/merge_requests/1#note_776723152

### Альтернативное предложение: отдельное версионирование приложений и схемы.

* Semver для схемы.
* Любой DDL выполняется в рамках инкремента версии схемы из любой версии приложения через глобальную синхронную кластерную операцию.
* В приложении разработчиком номинально задается совместимость с мажорной и минорной версией схемы. Версия схемы всегда растет. Если в приложении задано, что оно совместимо с версией схемы 2.2.0, то это приложение должно:
    * содержать в себе код, который при запуске первым делом будет пытаться
       идемпотентно обновить версию схемы в кластере до 2.2.0 и не начинать работу до момента, когда совместимая версия схемы применятся локально на инстансе.
    * запускаться только на версиях схемы >=2.2.0,<3.0.0, иначе падать.
* Откатить версию схемы нельзя (кроме восстановления из бэкапа).
* Если текущая версия схемы в кластере больше, чем версия схемы, которую пытается выкатить приложение, происходит ошибка, и приложение падает.
* Если версия приложения номинально совместима с версией схемы, но фактически оказалась несовместима, то решить проблему нужно, выпустив и выкатив новую версию приложения, которое включает в себя fix кода и/или код обновления версии схемы в кластере для восстановления совместимости.
    
#### Пример ситуации, когда фактическая совместимость отличается от номинальной

Работало в кластере с версией схемы 1.1.1 приложение версии 2.2.2. При выкатке обновления приложения на v2.3.0, в котором номинально задана совместимость со схемой v1.1.1, выяснилось, что в приложении название колонки в таблице содержит опечатку. Для решения проблемы есть несколько выходов:

* Откатить приложение до версии 2.2.2.
* Выпустить и выкатить bugfix приложения v2.3.1, где исправить опечатку. Номинальную совместимость с версией схемы оставить v1.1.1.
* Выпустить и выкатить bugfix приложения v2.3.1, включающий обратнонесовместимое обновление схемы на v2.0.0 с переименованием колонки в таблице для соответствия имени колонки в коде приложения. В этой версии приложения номинально должна быть задана совместимость с версией схемы v2.0.0.

#### Пример приложения

```python
fn main(app) {
    # added in app v5.0.0
    app.cluster.update_schema("1.0.0",
        [
            sql("create table t ..."),
            sql("create index i1 on t ..."),

            # можно также добавить данные в справочники
            sql("insert into t values ..."),
        ]
    )

    # added in app v5.1.0
    app.cluster.update_schema("1.1.0",
        # обратно совместимые изменения
        [sql("alter table t add col42 text null")]
    )

    # added in app v5.2.0
    app.cluster.update_schema("2.0.0",
        # обратно несовместимые изменения схемы
        [sql("rename table t to t2") ]
    )

    # fails with ERROR: cannot update schema version: current (2.0.0) >= new (1.99.0)
    app.cluster.update_schema("1.99.0", [ sql("...") ] )

    # Все еndpoints и background jobs всегда объявляются в каждой версии приложения
    endpoint("ep1", ...)
    endpoint("ep2", ...)

    background_job(...)
}
```

#### Как должен работать вызов update_schema

При старте приложение первым делом накатывает в свой replicaset все примененные в кластере изменения схемы из глобального кластерного хранилища, это библиотечная функция из SDK.
Далее в пользовательском коде выполняется блокирующий вызов `update_schema(version: str, ddl_commands: array[Command])`, внутри которого:

1. Все версии схемы сохраняются в глобальном хранилище в кластере.
2. Список команд из нового изменения схемы записывается в raft log
3. Возврат только когда все команды выполнятся на majority
4. Список команд и обновление версии схемы в каждом инстансе выполняется транзакционно.
5. Обновление работает только если текущая версия схемы в кластере меньше предлагаемой в обновлении. Иначе ошибка.
6. Повторный вызов ранее успешно завершившегося вызова с той же версией это noop.

#### Проблемы

* drop index не работает в транзакции
* Приложения, где нужно динамически создавать и удалять spaces.
* От разработчиков требуется некоторая дисциплина: код изменений схемы нелья менять, можно только добавлять новый.
  Изменение кода схемы под уже примененной в проде версией схемы может привести к неопределенному поведению. Но соблюдать
  эту дисциплину относительно просто, потому что ее правила можно довольно кратко описать в документации. Кроме того, 
  разработчики, которые раньше работали с базами данных, уже привыкли к тому, что в проекте есть код миграций, который не
  принято менять мосле релиза.

#### Ключевые особенности

* Приложения версионируются привычным разработчикам способом: захардкоженная константа содержит версию, и этой же версии соответствует tag в Git и релизные артефакты.
* Схема является частью кода приложения, но версионируется независимо от приложения
* Версионирование схемы глобальное в кластере, версионирование приложения локальное для инстанса
* Изменения схемы строго последовательные
* Приложение имеет номинальную зависимость от версии схемы в соответствии с правилами Semantic Versioning. Эта зависимость задается максимальным значением параметра `version` в последовательности вызовов метода `update_schema(version, commands)`.
* Все еndpoints и background jobs всегда объявляются в каждой версии приложения один раз
* Версия приложения зависит от API endpoints/rpc и background jobs, т.е. от функций кластера, которые видны пользователям API кластера и которые взаимодействуют с внешними системами. Сломали API -- нужно выпустить новую мажорную версию приложения. Сломали схему -- нужно присвоить коду обновления схемы новую мажорную версию.
* За изменения схемы ответственны и разработчики, т.к. они написали код изменений, зарелизили и выкатили приложение. Разработчики таким образом знают, какие изменения схемы под какими версиями долши до Production.
* Текущую версию схемы можно запросить в любой момент с любого узла кластера.
* Относительно легко можно понять, сможет ли определенная версия приложения выкатиться в существующий кластер. Для этого нужно сравнить максимальную версию схемы в приложении с текущей версией схемы в кластере.

#### Admin tools

Нужна команда, выводящая примерно такую таблицу

```
SUBJECT            | SCHEMA_VERSION   | APP_VERSION 
----------------------------------------------------
cluster (majority) | 2.0.0            | - 
instance1          | 2.0.0            | 5.1.0
instance2          | 1.2.3 (updating) | 4.3.2
instance3          | 2.0.0            | 5.1.0
```