status: accepted

decision-makers: @kostja, @funbringer

---

# Поддержка асинхронных уведомлений о смене топологии в pgproto для клиентов

## Мотивация

Ранее @kostja утвердил `pgwire` — совместимый с PostgreSQL протокол —
в качестве основного способа взаимодействия Picodata с клиентами.
Реализация протокола находится в модуле [pgproto](https://git.picodata.io/core/picodata/-/tree/master/src/pgproto).

Picodata — это распределенная СУБД.
Топология ее кластера — наборы узлов, их роли и доступность
может изменяться с течением времени. Клиенты должны быть
осведомлены о текущей топологии кластера. От этого напрямую
зависит корректность и производительность взаимодействия
с базой данных:

- Реализация `bucket/tier-aware` маршрутизации возможна
  только при наличии такой информации у клиента.
- При изменении топологии кластера и/или распределения
  bucket'ов, необходимо обновить внутренний маппинг клиента
  для отправки запросов на инстанс с соответствующими данными.

В предлагаемой архитектуре клиент подписывается на уведомления.
После этого, сервер сам отправляет их при возникновении изменений
в топологии.
Данный подход легко масштабируется, а главное стандартизирован
для всех.

## Продуктовые требования

Механизм уведомлений о смене топологии должен решать ключевую задачу —
обеспечить клиентам своевременное и надёжное отслеживание изменений
топологии кластера Picodata.

Продуктовые требования включают в себя следующие свойства системы:

1. **Поддержка "умных" клиентов**
   При получении определенного заголовка от клиента, сервер устанавливает
   связь с этим клиентом. После завершения подключения, клиент должен
   получать всю необходимую информацию об изменениях топологии.
   Прием сообщений должен происходить без дополнительных SQL запросов.
2. **Событийно-ориентированная модель**
   Механизм должен использовать реактивную модель доставки уведомлений,
   основанную на событиях. При возникновении события, сервер сразу отправляет
   сообщение клиенту.
3. **Простая расширяемость типов событий**
   Архитектура должна допускать простое добавление новых типов событий
   на стороне сервера и клиента.

## Варианты решения

### Вариант 1. Реализовать систему асинхронных уведомлений LISTEN+NOTIFY (отвергнут)

Предлагается реализовать механизм LISTEN/NOTIFY, [аналогичный из PostgreSQL](https://www.postgresql.org/docs/current/sql-notify.html).
Необходимо:

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

**Недостатки данного подхода для текущих целей:**

- значительная сложность реализации: требуется расширить pgproto
  совершенно новой логикой, в дальнейшем её поддерживать.
- необходимость интеграции с общей транзакционной моделью,
  хотя наша задача — отдельные события о топологии,
  а не полноценный LISTEN/NOTIFY.

В результате этот вариант был отвергнут как избыточный для нашей задачи.

### Вариант 2. Отправлять асинхронные сообщения NOTICE через служебное подключение (принят)

Вместо полноценной реализации LISTEN/NOTIFY было решено использовать
уже существующую возможность pgwire — отправку асинхронных сообщений
типа `NOTICE` по служебному подключению (клиенту).

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

- Принцип неявной подписки: клиент устанавливает
  служебное соединение c сервером и помечает его
  специальной KV-парой в [start-up message](https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-START-UP).
  Сервер регистрирует это подключение у себя, что
  позволит отправлять сообщения только нужным
  клиентам.
- После установки соединения и отправки `ReadyForQuery`
  сообщения, сервер сможет передавать клиентам
  сообщения о смене топологии.
- Отправка уведомлений может выполняться в
  любой момент, т.е. сразу при фиксации события
  в кластере и составлении сообщения.

**Преимущества подхода:**

- легковесный и решает только поставленную задачу,
  не создавая новых.
- минимальная сложность реализации на стороне сервера:
  NOTICE-сообщения имеют отдельный тип по стандарту,
  pgproto может отправлять их без дополнительных фич.
- простота интеграции для клиентов: служебное соединение
  принимает только уведомления, без выполнения SQL.
- полностью асинхронная модель доставки без
  переодических опросов.

## Реализация

### Общие положения

1. Событие - это объект, содержащий поля для
   описания произошедшего измненения.
   В нашем случае, сообщение описывает изменение
   топологии в кластере Picodata. Каждый объект
   имеет поле, определяющее тип события и данные,
   описывающие это событие. Формат - JSON.
2. Структура сообщения позволяет сделать их
   обработку на стороне клиента идемпотентной —
   повторная обработка не должна изменять
   состояние клиента.

### Алгоритм работы

При возникновении очередного изменения, Picodata в роли сервера:

1. собирает необходимую информацию по событию
2. формирует сообщение в формате JSON
3. отправляет сообщение через служебное подключение клиенту

В свою очередь, клиент:

1. получает сообщение
2. парсит сообщение
3. производит действия с представлением топологии

В Raft журнал имеет записи, которые проходят несколько этапов.
Запись считается **committed**, когда она реплицирована на кворум
серверов и гарантированно сохранится. **Applied** — это действие,
означающее, что committed-запись была применена к таблицам,
изменив их содержание.

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

Для этого для каждого клиента необходимо хранить его текущий прогресс:
значения raft [term и index](#raft). При появлении новых committed записей,
инстанс сверяет их индекс с прогрессом каждого соединения и отправляет
необходимые данные.

#### CDC для таблиц

_Change Data Capture_ (CDC) — это технология, которая позволяет отслеживать
операции вставки, обновления и удаления записей, происходящие в БД, и
передавать их в целевые системы.

Необходимая информация о топологии репликасетов и бакетов
хранится в глобальных таблицах:

- `_pico_instance`
- `_pico_replicaset`
- `_pico_peer_address`
- `_pico_bucket`

Для реализации отправки уведомлений, достаточно отслеживать
изменения в некоторых колонках этих таблиц:

| Table               | Columns             | Type    | Event                                                         |
| ------------------- | ------------------- | ------- | ------------------------------------------------------------- |
| \_pico_instance     | current_state       | replace | смена статуса инстанса: Offline/Online/Expelled               |
| \_pico_instance     |                     | delete  | удаление (expel) инстанса из состава кластера                 |
| \_pico_replicaset   | current_master_name | replace | смена роли инстанса в репликасете                             |
| \_pico_replicaset   |                     | delete  | удаление репликасета после удаления последнего инстанса в нем |
| \_pico_peer_address | address             | replace | смена адреса инстанса                                         |
| \_pico_bucket       | state               | replace | миграция бакетов между репликасетами                          |

Допускается при изменении колонки использовать другие из
той же строки для получения доп. информации.

#### Схемы таблиц

##### \_pico_instance

```sql
CREATE TABLE _pico_instance (
    -- Уникальное имя инстанса
    name              TEXT     NOT NULL,
    -- Уникальный uuid инстанса
    uuid              TEXT     NOT NULL,
    -- Уникальный raft_id инстанса
    raft_id           UNSIGNED NOT NULL,
    -- Имя репликасета, к которому принадлежит инстанс
    replicaset_name   TEXT     NOT NULL,
    -- Текущее состояние инстанса [State, Incarnation]
    current_state     ARRAY    NOT NULL,
    target_state      ARRAY    NOT NULL,
    failure_domain    MAP      NOT NULL,
    -- Имя тира, к которму принадлежит инстанс
    tier              TEXT     NOT NULL,
    picodata_version  TEXT     NOT NULL,

    PRIMARY KEY (name, uuid, raft_id)
)
USING MEMTX
DISTRIBUTED GLOBALLY;
```

##### \_pico_replicaset

```sql
CREATE TABLE _pico_replicaset (
    -- Уникальное имя репликасета
    name                   TEXT     NOT NULL,
    -- Уникальноый uuid репликасета
    uuid                   TEXT     NOT NULL,
    -- Имя инстанса, являющиегося лидером репликасета
    current_master_name    TEXT     NOT NULL,
    target_master_name     TEXT     NOT NULL,
    tier                   TEXT     NOT NULL,
    weight                 DOUBLE       NULL,
    weight_origin          TEXT     NOT NULL,
    state                  TEXT     NOT NULL,
    current_config_version UNSIGNED NOT NULL,
    target_config_version  UNSIGNED NOT NULL,
    promotion_vclock       MAP      NOT NULL,

    PRIMARY KEY (name, uuid)
)
USING MEMTX
DISTRIBUTED GLOBALLY;
```

##### \_pico_peer_address

```sql
CREATE TABLE _pico_peer_address (
    -- Уникальный raft_id инстанса
    raft_id         UNSIGNED NOT NULL,
    -- Адрес инстанса
    address         TEXT     NOT NULL,
    -- Тип адреса (iproto/pgproto)
    connection_type TEXT     NOT NULL,

    PRIMARY KEY (raft_id, connection_type)
)
USING MEMTX
DISTRIBUTED GLOBALLY;
```

##### \_pico_bucket

```sql
CREATE TABLE _pico_bucket (
    tier_name       TEXT     NOT NULL,

    -- айди первого бакета в данном интервале бакетов
    bucket_id_start UNSIGNED NOT NULL,

    -- айди последнего бакета в данном интервале бакетов
    bucket_id_end   UNSIGNED NOT NULL,

    state           TEXT     NOT NULL,
    -- возможные состояния:
    -- - 'active'  -- Бакет готов к использованию.
    --                При этом target_replicaset_name == current_replicaset_name
    -- - 'copying' -- Бакет в процессе копирования с одного репликасета на другой.
    --                При этом target_replicaset_name != current_replicaset_name.
    -- - 'copied'  -- Бакет полностью скопирован перенесён на новый репликасет
    --                но и на старом всё ещё находится его полная копия.
    --                При этом target_replicaset_name != current_replicaset_name.
    -- - 'aborting-copy' -- Перенос бакета отменён, его владение передаётся
    --                      обратно на current_replicaset_name, а
    --                      target_replicaset_name, должен удалить свою копию данных.

    -- репликасет, которому в данный момент принадлежит или принадлежал ранее этот бакет
    current_replicaset_name TEXT NOT NULL,

    -- репликасет, которому должен начать принадлежать (если ещё не принадлежит) этот бакет
    -- (в качестве оптимизации можно значение NULL расценивать как равное current_replicaset_name)
    target_replicaset_name  TEXT     NULL,

    PRIMARY KEY (tier_name, bucket_id_start)
)
USING MEMTX
DISTRIBUTED GLOBALLY;
```

#### События

##### Модель

В событийной модели, каждое сообщение должно представлять
только факт изменения, т.е. содержать только изменившиеся
данные.

Это позволяет:

1. упростить обработку событий, изменяя только нужное свойство
2. уменьшить трафик и нагрузку на клиентов

По принципу CDC, сервер отправляет события _как оно есть_.
То есть сообщение **один в один** отражает изменение,
произошедшее на стороне сервера. Продумывание обработки
событий на стороне клиента полностью ложится на разработчика
этого клиента.

Так как события это отражение операций над таблицей, то
каждое сообщение должно содержать тип операции и имя
целевой таблицы в поле `"op"`:

- **replace** соответствует операциям insert/update
- **delete** соответствует операции delete

Далее следует информация, описывающая событие.
Для идентификации репликасетов и инстансов используются
их уникальные uuid.

##### Raft

Дополнительно, каждое сообщение содержит поле `raft`.
Этот объект содержит поля `term` и `index`. Они нужны,
чтобы клиенты могли обрабатывать события в порядке,
определяемом алгоритмом Raft:

```rust
struct Raft{
  term: u64,
  index: u64
}
```

В общем случае это поле является ключом идемпотентности:
оно помогает сверять и обрабатывать устаревшие или
пропущенные события: `term=7, index=42` всегда позже,
чем `term=6, index=105`.

##### Структуры событий

Так как каждая системная таблица имеет префикс `_pico`,
можно отбросить его при передаче в сообщении,
в поле `"map"`.

События делятся на 3 логических группы и соответствуют
таблицам:

- replicaset → `_pico_replicaset`
- instance → (`_pico_instance`, `_pico_peer_address`)
- bucket → `_pico_bucket`

На стороне сервера и клиентов достаточно определить 3 структуры:

```rust
struct EventReplicaset {
  pub op: String,
  pub map: String,
  pub timestamp: String,
  pub raft: Raft,

  pub replicaset_uuid: String,
  pub current_master_uuid: Option<String>,
}

struct EventInstance {
  pub op: String,
  pub map: String,
  pub timestamp: String,
  pub raft: Raft,
  
  pub tier: Option<String>,
  pub replicaset_uuid: Option<String>,
  pub instance_uuid: String,
  pub current_state: Option<String>,
  pub address: Option<String>
}

struct EventBuckets {
  pub op: String,
  pub map: String,
  pub timestamp: String,
  pub raft: Raft,
  
  pub tier: String,
  pub state: String,
  pub bucket_id: Range
  pub current_replicaset_uuid: String
}

struct Range {
  pub start: u64,
  pub end: u64
}
```

[Выше](#cdc-для-таблиц) мы обсудили какие изменения топологии (события) необходимо отслеживать.
Мы можем описать событие конкретным набором полей. Заметим, что некоторые структуры имеют опциональные поля. 
Если в рамках события какие-то поля не используются, их значения будут None. При сериализации они не будут включены в итоговое JSON-сообщение. Это позволяет однозначно определить какой тип события необходимо обработать на клиенте и избежать сериализации и передачи по сети лишней информации.

Ниже представлены примеры сообщений для каждого события.

##### Примеры

###### Добавление репликасета

```json
{
  "op": "replace",
  "map": "replicaset",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
}
```

###### Удаление репликасета

```json
{
  "op": "delete",
  "map": "replicaset",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
}
```

###### Добавление инстанса

```json
{
  "op": "replace",
  "map": "instance",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "tier": "default",
  "replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
  "instance_uuid": "268a62a-8af6-4a3d-9f24-23e6c5f4bb32",
  "current_state": "Online",
  "address": "127.0.0.1:1333",
}
```

###### Изменение cтатуса инстанса

```json
{
  "op": "replace",
  "map": "instance",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "instance_uuid": "268a62a-8af6-4a3d-9f24-23e6c5f4bb32",
  "current_state": "Offline",
}
```

###### Удаление инстанса

```json
{
  "op": "delete",
  "map": "instance",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "instance_uuid": "268a62a-8af6-4a3d-9f24-23e6c5f4bb32",
}
```

###### Изменение роли инстанса

В репликасете не может быть больше одного лидера.
При его смене, предыдыдущий лидер автоматически
становится репликой. Поэтому, достаточно иметь
только одно событие - смену лидера.
Данное поведение стоит учитывать при разработке
клиентов и правильно изменить представление топологии
на нем.

Если, после смены лидера, второй не был избран,
то и никакого события не будет отправлено клиенту.
Это правильный подход, так как допускается
отправлять запросы на старого лидера, пока новый
не был избран.

```json
{
  "op": "replace",
  "map": "replicaset",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "replicaset_uuid": "2638a62a-8af6-4a3d-9f24-23e6c5f4bb32",
  "current_master_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
}
```

###### Изменение адреса инстанса

Информация об адресе инстанса находится в
таблице `_pico_peer_address`. Будет логично
эту информацию объединить с представлением
каждого инстанса на клиенте. Поэтому, событие
изменения адреса имеет поле `"map"` cо
значением `"instance"`.

```json
{
  "op": "replace",
  "map": "instance",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "instance_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
  "address": "127.0.0.1:1333",
}
```

###### Миграция бакетов между репликасетами

Согласно схеме таблицы [\_pico_bucket](#_pico_bucket),
процесс миграции бакетов отображается в колонке `state`.
Отправлять сообщение о миграции бакетов нужно
после того, как закончилась миграция - колонка `state`
имеет значение `copied`.
После каждой успешной миграции отправляется сообщение.

```json
{
  "op": "replace",
  "map": "bucket",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "tier": "default",
  "state": "copied",
  "bucket_id": {
    "start": 0,
    "end": 1000
  },
  "current_replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
}
```

### Изначальное получение топологии

#### Клиентский handshake

При подключении клиент отправляет специальный заголовок серверу -
startup message, в виде объекта ключ-значение. Мы можем встроить
любую информацию в этот заголовок, в частности:

```c
"picodata_catalog_cdc": 1,
```

Наличие этого поля сообщает серверу, что он должен отправлять
события об изменениях в топологии. В качестве значения клиент
передает серверу версию расширения протокола, которую он сможет
корректно обработать. Это позволит версионировать протокол без
поломок драйвера.

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

Для отладки протокола через psql также следует рассмотреть возможность
передачи флага `picodata_catalog_cdc` через `options`. Это позволит 
получать и отображать служебные сообщения прямо в psql:

```shell
psql postgres://localhost:4327?options=picodata_catalog_cdc%3D1
```

#### Отправка снапшота топологии

##### Описание

После завершения [handshake](#клиентский-handshake),
на сервере вызывается отдельный метод сборки и
отправки снепшота топологии клиенту.

Информация о состоянии кластера (топологии) хранится в _глобальных таблицах_.
Это позволяет читать их с любого инстанса, независимо от его роли.

##### Порядок отправки

Cнепшот состоит из трёх частей:

- Cообщения c [топологией репликасетов](#добавление-репликасета)
- Сообщения с [топологией узлов](#изменение-cтатуса-инстанса)
- Сообщения с [топологией бакетов](#миграция-бакетов-между-репликасетами)

Необходимо соблюсти такой порядок отправки сообщений,
чтобы на стороне клиента инциализировать все репликасеты
и входящие в них инстансы. Затем, обработать маппинг бакетов
на эти репликасеты.

##### Алгоритм отправки

Инстанс-поставщик, с которым клиент держит служебное
подключение, собирает текущие данные из [глобальных таблиц](#схемы-таблиц),
составляет [сообщения](#примеры) типа [replace](#модель).

Сначала отправляются сообщения о репликасетах.

```json
{
  "op": "replace",
  "map": "replicaset",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
}
```

Затем, отправляются сообщения об инстансах.

```json
{
  "op": "replace",
  "map": "instance",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "tier": "default",
  "replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
  "instance_uuid": "268a62a-8af6-4a3d-9f24-23e6c5f4bb32",
  "current_state": "Offline",
  "address": "127.0.0.1:1333",
}
```

В конце отправляются сообщения с топологией бакетов. В отличии от
сообщений [миграции бакетов](#миграция-бакетов-между-репликасетами),
в снепшоте мы фиксируем актуальное состояние топологии бакетов.
Берутся все записи, в которых колонка `state` имеет значение `active`.

```json
{
  "op": "replace",
  "map": "bucket",
  "timestamp": "2025-08-19T11:24:28+00:00",
  "raft": {
    "term": 1,
    "index": 2
  },
  "tier": "default",
  "state": "active",
  "bucket_id": {
    "start": 0,
    "end": 1000
  },
  "current_replicaset_uuid": "9e273105-5af8-4f77-8f47-3d9a68f772ca",
}
```

Только после снапошота, сервер отправляет `ReadyForQuery`
сообщение клиенту. Это позволит обрабатывать случай, когда
снапшот не был получен клиентом.

### Восстановление топологии

При создании служебного подключения, клиенту достаточно
очистить представление о топологии и обработать сообщения
от сервера.

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

Возможна и обратная ситуация - подключение к опережающему
инстансу. Тогда, клиент получит снапшот, в котором части
старых инсансов уже не будет, потому что они были изгнаны.
Соответственно, у клиента в представлении топологии будут
мертвые записи, если он не очищает его при создании
нового соединения.

Подход решает 2 проблемы:

1. Начальное получение топологии кластера.
2. Восстановление топологии после обрыва служебного
   соединения между сервером и клиентом.

### Обработка обновления маппинга бакетов

Как уже говорилось, сервер должен отправлять
сообщения сразу как произошло событие.

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

В таком случае, предлагает реализовать агрегацию событий
средствами клиента и осуществлять обновление маппинга
по двум критериям одновременно:

- накопилась пачка апдейтов (например, 2000 записей)
- сработал очередной таймаут (например, 15 секунд)

### Обработка failover

Failover внутри репликасета - ситуация, при которой инстанс-реплика
становится лидером. В таком случае, нужно просто отправить событие
[изменения лидера репликасета](#изменение-роли-инстанса) и обработать
на клиенте.
