status: accepted

decision-makers: @kostja, @funbringer

---

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

## Мотивация

При получении SQL запроса, Пикодате необходимо вычислить `bucket_id` и
отправить запрос на нужный репликасет. Из-за лишних пересылок по сети
падает общая производительность системы.

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

На стороне сервера, запросы от клиента сначала парсятся, затем
анализируются и переписываются. Результат (`Prepared Statement`)
записывается в специальный кэш.
При повторном получении запроса, сервер будет использовать Prepared Statement
из кэша. Такой подход исключает повторный разбор запроса.

Оптимизация отправки запросов на конкретный репликасет Пикодаты выглядит следующим образом:
- Cтруктура `Plan` в `Prepared Statement` содержит информацию о позициях колонок таблицы, 
  в которые происходит вставка параметров.
- Эта информация позволяет сравнить позиции колонок шардирования и массива параметров.
- Результат сравнения - список индексов массива параметров, которые соответствуют колонкам 
  шардирования таблицы. Эта информация отправляется клиенту.
- На клиенте, информация записывается в кэш, чтобы рассчитать `bucket_id` перед отправкой запроса.

Такой подход имеет ряд преимуществ:

- **Снижение нагрузки**: вычисление `bucket_id` происходят на стороне клиента, 
  что снижает нагрузку на сервер и убирает лишние пересылки по сети.
- **Умная обработка отказов**: в случае сбоя узла или изменения схемы таблицы, 
  клиент может повторно вычислить `bucket_id`.

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

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

Больше информации можно узнать в [этой](#клиентский-handshake) главе.

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

### Обработка запросов в pgwire

В extended query режиме, протокол pgwire разделяет выполнение запроса на стадии:

- `Parse` — сервер разбирает SQL-запрос и создаёт Prepared Statement.
- `Bind` — сервер связывает параметры с этим Prepared Statement и создает Portal.
- `Execute` — сервер исполняет Portal.

На этапе `Parse` сервер либо возвращает ошибку, либо `Parse Complete` сообщение.
Именно на этом этапе необходимо отправлять дополнительное `Notice` сообщение,
перед `Parse Complete`. 

Так клиент получит сообщения в правильном порядке: после отправки `Parse`,
клиент получит [метаданные](#структура-preparedstatementmetadata). После 
остальных команд - `Prepared Statement`.

Ниже представлена структура `Notice`-сообщения, содержащего метаинформацию.

### Структура PreparedStatementMetadata

```rust
struct PreparedStatementMetadata {
   // Текст исходного SQL запроса.
   // Необходим для создания имени 
   // Prepared Statement на клиенте.
   query: String,

   // Имя тира, в котором
   // лежат таблицы запроса.
   tier: String,

   // Метаданные SQL запроса.
   // Содержит позиции ключей распределения 
   // в массиве параметров запроса.
   dk_cols: Vec<u64>
}
```

### DDL invalidation

- Возможна ситуация, когда схема таблицы на сервере изменится из-за изменения 
  состава колонок или удаления таблицы. В таком случае, Prepared Statement, 
  которые ссылаются на эту таблицу, становятся невалидными. При попытке использовать 
  Prepared Statement, сервер должен сверить закешированные версии схемы таблиц в 
  Prepared Statement c актуальными в Catalog. Если отличаются  - сервер обязан 
  отправить клиенту ошибку вида:

   ```c
   ERROR: prepared statement has been invalidated, reprepare is required (SQLSTATE: 42999)
   ```

  После получения такой ошибки, клиент выполняет reprepare - закрывает устаревший 
  Prepared Statement и отправляет `Parse`-запрос повторно. Перед отправкой новой ошибки 
  требуется расширить функционал `pgproto` определением нового типа и кода ошибки. 
- При установке соединения с сервером (инстансом), клиент обязан
  очистить кэш. Это один из шагов по восстановлению топологии, подробнее
  можно узнать [в этом ADR](https://git.picodata.io/core/picodata/-/blob/master/doc/adr/2025-09-12-pgproto-topology-notifications.md?ref_type=heads#%D0%B2%D0%BE%D1%81%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5-%D1%82%D0%BE%D0%BF%D0%BE%D0%BB%D0%BE%D0%B3%D0%B8%D0%B8).

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

Для получения `Notice`-сообщений с метаданными запроса после этапа `Parse` и нового типа ошибки при `Bind` невалидного `Prepared Statement`, клиент указывает в заголовке (startup message) специальную kv-пару или использует option:

```c
"picodata_stmt_invalidation" = true
```

Аналогичное поведение описано [в этом ADR](https://git.picodata.io/core/picodata/-/blob/master/doc/adr/2025-09-12-pgproto-topology-notifications.md?ref_type=heads#%D0%BA%D0%BB%D0%B8%D0%B5%D0%BD%D1%82%D1%81%D0%BA%D0%B8%D0%B9-handshake).

### Unbounded queries

Для ряда запросов Picodata **не может** однозначно вычислить `bucket_id`.
Если массив `dk_cols` в [PreparedStatementMetadata](#структура-preparedstatementmetadata)
не пустой - клиент может рассчитать для запроса bucket_id.
В ином случае, запрос отправляется на рандомный инстанс.

Ниже перечислены несколько частных случаев, при которых нельзя однозначно
определить `bucket_id`:

1. **DML/DQL без фильтрации по distributed by колонкам**
   Без условия по `distributed by` колонкам невозможно определить
   какие записи удалять/обновлять и на каких инстансах это делать.

   **Пример:**

   ```sql
   CREATE TABLE foo(a INT PRIMARY KEY, b TEXT) USING MEMTX DISTRIBUTED BY (a);

   SELECT * from foo;
   UPDATE foo SET b = $1::text;
   ```

2. **DML/DQL c неполным набором distributed by колонок**  
   Без полного набора distributed by колонок невозможно вычислить `bucket_id`.

   **Пример:**

   ```sql
   CREATE TABLE foo(a INT PRIMARY KEY, b INT, c DECIMAL) USING MEMTX DISTRIBUTED BY (a,b);

   SELECT a,b FROM foo WHERE a = $1::integer;
   ```

3. **Distributed JOIN**  
   Если соединение не является equi-join по ключу распределения таблиц
   или оно выполняется с глобальной таблицей, то такой JOIN становится
   распределённым. Сервер вынужден собирать данные с нескольких инстансов,
   и локальность теряется.

   **Пример:**

   ```sql
   CREATE TABLE foo(a INT PRIMARY KEY, b TEXT) USING MEMTX DISTRIBUTED BY (a);
   CREATE TABLE bar(b TEXT PRIMARY KEY, c DOUBLE) USING MEMTX DISTRIBUTED BY (b);

   SELECT foo.a, foo.b FROM foo JOIN bar ON foo.b = bar.b;
   ```

4. **Subquery**  
   Параметры используются для вычисления `bucket_id`.  
   Но значения некоторых неизвестны до выполнения подзапроса.

   **Пример:**

   ```sql
   CREATE TABLE foo(a INT PRIMARY KEY, b TEXT) USING MEMTX DISTRIBUTED BY (a);
   CREATE TABLE bar(b INT PRIMARY KEY, c double) USING MEMTX DISTRIBUTED BY (b);

   INSERT INTO foo (a,b) VALUES ((SELECT cast(max(c) AS INT) FROM bar WHERE b > $1::integer), $2::text);
   SELECT a FROM foo WHERE a > (SELECT cast(max(c) AS INT) FROM bar WHERE b > $1::integer);
   ```

На стороне клиента можно логировать такие SQL-запросы,
если не удалось применить оптимизацию. Такая логика поможет 
расследовать низкую производительность запроса.

### Алгоритм вычисления bucket_id

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

После этапа Parse, объект Prepared Statement будет хранить pgwire-типы для 
каждого переданного параметра. Каждому такому типу соответствует его уникальный 
код (OID) и текстовое название. Мы будем использовать их для правильного кодирования
этого параметра.

Алгоритм выглядит следующим образом:

1. Клиент получает список параметров для хеширования.
2. Инициализируется начальное состояние `MurmurHash`-объекта SEED равным `13 (uint32)`.
3. В цикле, для каждого параметра в списке: 
   1. По соответствию `OID -> тип`, определяется тип параметра.
   2. Значение параметра кодируется в бинарное представление `MessagePack`, согласно
      [некоторым правилам](#сериализация-параметров-messagepack).
   3. Бинарное представление (массив байт) подается на вход функции хеширования.
4. После хеширования всех параметров, вычисляется конечный хэш.
5. Конечный хэш делится по модулю на количество бакетов в кластере плюс один. 
6. Результатом работы алгоритма является `bucket_id` - uint32 значение. 

#### Хеширование параметров (MurmurHash)

Чтобы преобразовать закодированные параметры в единое число (`bucket_id`),
используется алгоритм [MurmurHash](https://en.wikipedia.org/wiki/MurmurHash).

С реализацией алгоритма можно ознакомиться в коде [tarantool-sys](https://git.picodata.io/core/tarantool/-/blob/2.11.5-picodata/third_party/PMurHash.c):

1. `PMurHash32_Process` отвечает за хеширование значений.
2. `PMurHash32_Result` отвечает за получение конечного хеша.

Или ознакомиться с исходным кодом одного из [умных драйверов](https://docs.picodata.io/picodata/stable/dev/connectors_index/).

#### Сериализация параметров (MessagePack)

Tarantool оперирует данными, закодированными в [Message Pack](https://github.com/msgpack/msgpack/blob/master/spec.md)(MP) формат. 
Перед хешированием параметра, необходимо закодировать его значение в соответствующий вид.

На сайте [msgpack.org](https://msgpack.org/) представлены библиотеки для языков программирования. 
Так же, можно найти логику кодирования  для каждого типа в [tarantool-sys](https://git.picodata.io/core/tarantool/): имена функций
придерживаются шаблона `mp_encode_*`, где `*` - название типа данных.

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

1. заголовок - декларирует тип и длину данных параметра
2. значение - хранит данные параметра

Обратим внимание, что Tarantool использует оптимизации хранения и кодирования данных. 
Нам нужно придерживаться их на клиенте, чтобы получить аналогичное представление. 
Рассмотрим алгоритмы кодирования для каждого типа данных в Picodata.

##### BOOLEAN

Стандартный алгоритм MessagePack.

##### DECIMAL

Бинарное представление Decimal:

```
                                           <------ payload data ----->
+--------+-------------------+------------+===============+===========+
| MP_EXT | length (optional) | MP_DECIMAL | scale         | BCD       |
| 1byte  |                   | 1byte      |               |           |
+--------+-------------------+------------+===============+===========+
                                           <------ length bytes ----->
```

Бинарное представление может отличаться, в зависимости от значения параметра.

1. Первый байт кодирует длину payload data. Если бинарное представление занимает 
   ровно 1/2/4/8/16 байт - будет использован `fixext 1/2/4/8/16`, поле `length` 
   отсутствовать. В других случаях, будет использоваться `ext 8/16/32` и поле `length` 
   хранить длину payload data. Байты для каждого имени формата можно найти в [документации](https://github.com/msgpack/msgpack/blob/master/spec.md#formats) MessagePack.
2. Следующий байт `0x01`, тип MP_DECIMAL.
3. scale - количество цифр после запятой. В зависимости от знака может быть int или uint.
   После определения нужного типа, кодируется по тем же правилам, что и тип [INTEGER](#integer).
4. BCD — это последовательность байтов, представляющих десятичные цифры закодированного числа. 
   Каждый байт состоит из двух десятичных цифр, каждая из которых закодирована с использованием 
   4-битных полубайтов: `байт >> 4` — первая цифра, а `байт & 0x0f` — вторая:
   
   ```
    value     BCD
   +---------+------------------+
   | 12.34   | 0x01, 0x23, 0x4c |
   |---------+------------------|
   | -12.345 | 0x12, 0x34, 0x5d |
   +---------+------------------+
   ```

   Первый полубайт хранит 0, если количество цифр в исходном числе четное.
   Последний полубайт последнего байта хранит `0x0D`, если исходное число отрицательное. Иначе `0x0C`.
   Крайняя левая цифра в массиве — старшая. Крайняя правая цифра в массиве — младшая.
5. При построении BCD, нужно убрать все старшие нули из представления. 
   Но оставить хотя бы один, если изначальное число равно 0. Они не влияют на числовое значение, 
   но занимают место и изменяют длину полезной нагрузки:

   ```
    value                    scale         BCD
   +------------------------+-------------+--------------+
   | 0.0000..10 (34 zeroes) |  36 (0x24)  |  0x01, 0x0c  |
   +------------------------+-------------+--------------+
   ```

Подробнее с форматом кодирования Decimal можно ознакомиться в [документации Tarantool](https://www.tarantool.io/en/doc/latest/reference/internals/msgpack_extensions/#the-decimal-type).
Код преобразования находится в [tarantool-sys/core/.../mp_decimal.c](https://git.picodata.io/core/tarantool/-/blob/2.11.5-picodata/src/lib/core/mp_decimal.c?ref_type=heads). Обратите внимание, что на вход функции подается `decimal_t*` - пользовательский тип. Его так же нужно перенести на сторону клиента.
Второй вариант - ознакомиться с исходным кодом одного из [умных драйверов](https://docs.picodata.io/picodata/stable/dev/connectors_index/).

##### DOUBLE (float32/float64)

Стандартный алгоритм MessagePack.

##### INTEGER

1. Если число ∊ `[0; INT64_MAX]` - оно будет представлено как uint.
2. Числа ∊ `[0; 127]` кодируются единственным байтом, без заголовка.
   Такие числа нужно обрабатывать отдельно:
3. Если значение умещается в `(u)int8/16/32/64` - оно будет 
   закодировано максимально компактным образом. Перед хешированием
   нужно привести переменную к минимально подходящему типу:

   ```go
   var val int64 = 1337
   var norm_val = uint16(1337)
   ```

4. В остальных случаях, число кодируется стандартным алгоритмом MessagePack.

##### TEXT/STRING/VARCHAR

Для кодирования строк не нужно использовать алгоритм
из MessagePack. Они кодируются как последовательность байт
и в таком виде сразу передаются на вход функции хеширования.

Комментарий из кода `tarantool-sys`:

```c
/*
 * (!) MP_STR fields hashed **excluding** MsgPack format
 * indentifier. We have to do that to keep compatibility
 * with old third-party MsgPack (spec-old.md) implementations.
 * \sa https://github.com/tarantool/tarantool/issues/522
 */
```

##### UUID

Бинарное представление UUID:

```
+----------+------------+-----------------+
| MP_EXT   | MP_UUID    | UuidValue       |
| = 0xd8   | = 0x02     | = 16-byte value |
+----------+------------+-----------------+
```

1. Первый байт: `0xd8`, длина 16 байт.
2. Второй байт: `0x02`, тип MP_UUID.
3. 16-ти байтовое представление UUID.

##### DATETIME

Бинарное представление Datetime:

```
+-------------+----------------+=========+-----------------+
| MP_EXT      | MP_DATETIME    | seconds | nsec; tzoffset; |
| = 0xd7/0xd8 | = 0x04         | 8bytes  | tzindex; 8bytes |
+-------------+----------------+=========+-----------------+
```

1. Первый байт: длина закодированных данных:
   1. `0xd7` - 8 байт;
   2. `0xd8` - 16 байт;
2. Второй байт: `0x04`, тип MP_DATETIME.
3. Следующие 8 байт - секунды. Представлены как int64 Unix timestamp в порядке little-endian.
4. Следующие 8 байт опциональные. Включают nsec, tzoffset и tzindex, в порядке little-endian.
