Compare commits
3 Commits
f37548a40e
...
e732cbea2d
Author | SHA1 | Date |
---|---|---|
Vitaliy Filippov | e732cbea2d | |
Vitaliy Filippov | c72e8e649e | |
Vitaliy Filippov | 8bdb3e8786 |
|
@ -11,14 +11,136 @@
|
|||
- [Differences from Ceph](#differences-from-ceph)
|
||||
- [Implementation Principles](#implementation-principles)
|
||||
|
||||
## Server-side components
|
||||
|
||||
- **OSD** (Object Storage Daemon) is a process that directly works with the disk, stores data
|
||||
and serves read/write requests. One OSD serves one disk (or one partition). OSDs talk to etcd
|
||||
and to each other — they receive cluster state from etcd, and send read/write requests for
|
||||
secondary copies of data to other OSDs.
|
||||
- **etcd** — clustered key/value database, used as a reliable storage for configuration
|
||||
and high-level cluster state. Etcd is the component that prevents splitbrain in the cluster.
|
||||
Data blocks are not stored in etcd, etcd doesn't participate in data write or read path.
|
||||
- **Монитор** — a separate node.js based daemon which monitors the cluster, calculates
|
||||
required configuration changes and saves them to etcd, thus commanding OSDs to apply these
|
||||
changes. Monitor also aggregates cluster statistics. OSD don't talk to monitor, monitor
|
||||
only sends and receives data from etcd.
|
||||
|
||||
## Basic concepts
|
||||
|
||||
- OSD (Object Storage Daemon) is a process that stores data and serves read/write requests.
|
||||
- PG (Placement Group) is a "shard" of the cluster, group of data stored on one set of replicas.
|
||||
- Pool is a container for data that has equal redundancy scheme and placement rules.
|
||||
- Monitor is a separate daemon that watches cluster state and handles failures.
|
||||
- Failure Domain is a group of OSDs that you allow to fail. It's "host" by default.
|
||||
- Placement Tree groups OSDs in a hierarchy to later split them into Failure Domains.
|
||||
- **Pool** is a container for data that has equal redundancy scheme and disk placement rules.
|
||||
- **PG (Placement Group)** is a "shard" of the cluster, subdivision unit that has its own
|
||||
set of OSDs for data storage.
|
||||
- **Failure Domain** is a group of OSDs, from the simultaneous failure of which you are
|
||||
protected by Vitastor. Default failure domain is "host" (server), but you choose a
|
||||
larger (for example, a rack of servers) or smaller (a single drive) failure domain
|
||||
for every pool.
|
||||
- **Placement Tree** (similar to Ceph CRUSH Tree) groups OSDs in a hierarchy to later
|
||||
split them into Failure Domains.
|
||||
|
||||
## Client-side components
|
||||
|
||||
- **Client library** incapsulates client I/O logic. Client library connects to etcd and to all OSDs,
|
||||
receives cluster state from etcd, sends read and write requests directly to all OSDs. Due
|
||||
to the symmetric distributed architecture, all data blocks (each 128 KB by default) are placed
|
||||
to different OSDs, but clients always knows where each data block is stored and connects directly
|
||||
to the right OSD.
|
||||
|
||||
All other client-side components are based on the client library:
|
||||
|
||||
- **[vitastor-cli](../usage/cli.en.md)** — command-line utility for cluster management.
|
||||
Allows to view cluster state, manage pools and images, i.e. create, modify and remove
|
||||
virtual disks, their snapshots and clones.
|
||||
- **[QEMU driver](../usage/qemu.en.md)** — pluggable QEMU module allowing QEMU/KVM virtual
|
||||
machines work with virtual Vitastor disks directly from userspace through the client library,
|
||||
without the need to attach disks as kernel block devices. However, if you want to attach
|
||||
disks, you can also do that with the same driver and [VDUSE](../usage/qemu.en.md#vduse).
|
||||
- **[vitastor-nbd](../usage/nbd.en.md)** — utility that allows to attach Vitastor disks as
|
||||
kernel block devices using NBD (Network Block Device), which works more like "BUSE"
|
||||
(Block Device In Userspace). Vitastor doesn't have Linux kernel modules for the same task
|
||||
(at least by now). NBD is an older, non-recommended way to attach disks — you should use
|
||||
VDUSE whenever you can.
|
||||
- **[CSI driver](../installation/kubernetes.en.md)** — driver for attaching Vitastor images
|
||||
as Kubernetes persistent volumes. Works through VDUSE (when available) or NBD — images are
|
||||
attached as kernel block devices and mounted into containers.
|
||||
- **Drivers for Proxmox, OpenStack and so on** — pluggable modules for corresponding systems,
|
||||
allowing to use Vitastor as storage in them.
|
||||
- **[vitastor-nfs](../usage/nfs.en.md)** — NFS 3.0 server allowing export of two file system variants:
|
||||
the first is a simplified pseudo-FS for file-based access to Vitastor block images (for non-QEMU
|
||||
hypervisors with NFS support), the second is **VitastorFS**, full-featured clustered POSIX FS.
|
||||
Both variants support parallel access from multiple vitastor-nfs servers. In fact, you are
|
||||
not required to setup separate NFS servers at all and use vitastor-nfs mount command on every
|
||||
client node — it starts the NFS server and mounts the FS locally.
|
||||
- **[fio driver](../usage/fio.en.md)** — pluggable module for fio disk benchmarking tool for
|
||||
running performance tests on your Vitastor cluster.
|
||||
- **vitastor-kv** — client for a key-value DB working over shared block volumes (usual
|
||||
vitastor images). VitastorFS metadata is stored in vitastor-kv.
|
||||
|
||||
## Additional utilities
|
||||
|
||||
- **vitastor-disk** — утилита для разметки дисков под Vitastor OSD. С её помощью можно
|
||||
создавать, удалять, менять размеры или перемещать разделы OSD.
|
||||
|
||||
## Общий процесс записи и чтения
|
||||
|
||||
- В Vitastor хранятся виртуальные диски, также называемые "образы" или "иноды".
|
||||
- Каждый образ хранится в определённом пуле. Пул определяет параметры хранения образов в нём,
|
||||
такие, как схему избыточности (репликация или EC — коды коррекции ошибок), домен отказа
|
||||
и ограничения по выбору OSD для размещения данных образов. См. [Конфигурация пулов](../config/pool.ru.md).
|
||||
- Каждый образ разбивается на объекты фиксированного размера, равного [block_size](../config/layout-cluster.ru.md#block_size)
|
||||
(по умолчанию 128 КБ), умноженному на число частей данных для EC или на 1 для реплик.
|
||||
То есть, например, если в пуле используется схема кодирования 4+2 (4 части данных + 2 чётности),
|
||||
то при стандартном block_size образы разбиваются на части по 512 КБ.
|
||||
- Клиентские запросы чтения/записи разделяются на части, соответствующие этим объектам.
|
||||
- Для каждого объекта определяется номер PG, которой он принадлежит, путём простого взятия
|
||||
остатка от деления ID образа и смещения на число PG в пуле, которому принадлежит образ.
|
||||
- Клиент читает информацию о первичном OSD всех PG из etcd. Первичный OSD каждой PG назначается
|
||||
монитором в процессе работы кластера, вместе с текущим целевым набором OSD этой PG.
|
||||
- Клиент соединяется (если ещё не соединён) с первичными OSD всех PG, объекты которых участвуют
|
||||
в запросе и одновременно отправляет им запросы чтения/записи частей.
|
||||
- Если какие-то из первичных OSD недоступны, клиент повторяет попытки соединения бесконечно до
|
||||
тех пор, пока они не станут доступны или пока PG не будут назначены другие первичные OSD.
|
||||
- Клиент также повторяет запросы, если первичные OSD отвечают кодом ошибки EPIPE, означающим,
|
||||
что запрос не может быть обработан в данный момент. Это происходит, если PG либо не активна
|
||||
вообще, либо не активна на данном OSD в данный момент (например, если в этот момент меняется
|
||||
её первичный OSD) или если в процессе обработки запроса сам первичный OSD теряет подключение
|
||||
к репликам.
|
||||
- Первичный OSD определяет, на каких OSD находятся части объекта. По умолчанию все объекты
|
||||
считаются находящимися на текущем целевом наборе OSD соответствующей PG, но какие-то могут
|
||||
находиться на других OSD, если эти объекты деградированы или перемещены, или идёт процесс
|
||||
ребаланса. Запросы для проверки по сети не отправляются, информация о местоположении всех
|
||||
объектов рассчитывается первичным OSD при активации PG и хранится в памяти.
|
||||
- Первичный OSD соединяется (если ещё не соединён) с вторичными OSD, на которых располагаются
|
||||
части объекта, и отправляет им запросы чтения/записи, а также читает/пишет из/в своё локальное
|
||||
хранилище, если сам входит в набор.
|
||||
- После завершения всех вторичных операций чтения/записи первичный OSD отправляет ответ клиенту.
|
||||
|
||||
### Особенности обработки запросов
|
||||
|
||||
- Если в пуле используются коды коррекции ошибок и при этом часть OSD недоступна, первичный
|
||||
OSD при чтении восстанавливает данные из оставшихся частей.
|
||||
- Каждый объект имеет номер версии. При записи объекта первичный OSD сначала читает из номер
|
||||
версии объекта. Так как первичный OSD обычно сам хранит копию или часть объекта, номер
|
||||
версии обычно читается из памяти самого OSD. Однако, если ни одна часть обновляемого объекта
|
||||
не находится на первичном OSD, для получения номера версии он обращается к одному из вторичных
|
||||
OSD, на которых копия объекта есть. Обращения к диску при этом всё равно не происходит,
|
||||
так как метаданные объектов, включая номер версии, все OSD хранят в памяти.
|
||||
- Если в пуле используются коды коррекции ошибок, перед частичной записью объекта для вычисления
|
||||
чётности зачастую требуется чтение частей объекта с вторичных OSD или с локального диска
|
||||
самого первичного OSD.
|
||||
- Также, если в пуле используются коды коррекции ошибок, для закрытия Write Hole применяется
|
||||
двухфазный алгоритм записи: сначала на все вторичные OSD записывается новая версия частей
|
||||
объекта, но при этом старая версия не удаляется, а потом, после получения подтверждения
|
||||
успешной записи от всех вторичных OSD, новая версия фиксируется и разрешается удаление старой.
|
||||
- Если в кластере не включён режим immediate_commit, то запросы записи, отправляемые клиентами,
|
||||
не считаются зафиксированными на физических накопителях сразу. Для фиксации данных клиенты
|
||||
должны отдельно отправлять запросы SYNC (отдельный от чтения и записи вид запроса),
|
||||
а пока такой запрос не отправлен, считается, что записанные данные могут исчезнуть,
|
||||
если соответствующий OSD упадёт. Поэтому, когда режим immediate_commit отключён, все
|
||||
запросы записи клиенты копируют в памяти и при потере соединения и повторном соединении
|
||||
с OSD повторяют из памяти. Скопированные в память данные удаляются при успешном fsync,
|
||||
а чтобы хранение этих данных не приводило к чрезмерному потреблению памяти, клиенты
|
||||
автоматически выполняют fsync каждые [client_dirty_limit](../config/network.ru.md#client_dirty_limit)
|
||||
записанных байт.
|
||||
|
||||
## Similarities to Ceph
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
- [Серверные компоненты](#серверные-компоненты)
|
||||
- [Базовые понятия](#базовые-понятия)
|
||||
- [Клиентские компоненты](#клиентские-компоненты)
|
||||
- [Дополнительные утилиты](#дополнительные-утилиты)
|
||||
- [Общий процесс записи и чтения](#общий-процесс-записи-и-чтения)
|
||||
- [Особенности обработки запросов](#особенности-обработки-запросов)
|
||||
- [Схожесть с Ceph](#схожесть-с-ceph)
|
||||
|
@ -34,8 +35,9 @@
|
|||
- **Пул (Pool)** — контейнер для данных, имеющих одну и ту же схему избыточности и правила распределения по OSD.
|
||||
- **PG (Placement Group)** — "шард", единица деления пулов в кластере, которой назначается свой набор
|
||||
OSD для хранения данных (копий или частей объектов).
|
||||
- **Домен отказа (Failure Domain)** — группа OSD, одновременное падение которых рассматривается
|
||||
как вероятное. По умолчанию это "host" (сервер).
|
||||
- **Домен отказа (Failure Domain)** — группа OSD, от одновременного падения которых должен защищать
|
||||
Vitastor. По умолчанию домен отказа — "host" (сервер), но вы можете установить для пула как больший
|
||||
домен отказа (например, стойку серверов), так и меньший (например, отдельный диск).
|
||||
- **Дерево распределения** (Placement Tree, в Ceph CRUSH Tree) — иерархическая группировка OSD
|
||||
в узлы, которые далее можно использовать как домены отказа.
|
||||
|
||||
|
@ -49,25 +51,39 @@
|
|||
|
||||
На базе клиентской библиотеки реализованы все остальные клиенты:
|
||||
|
||||
- **vitastor-cli** — утилита командной строки для управления кластером. В данный момент позволяет
|
||||
просматривать общее состояние кластера и управлять образами — т.е. создавать, менять и удалять
|
||||
виртуальные диски, их снимки и клоны.
|
||||
- **Драйвер QEMU** — подключаемый модуль QEMU, позволяющий QEMU/KVM виртуальным машинам работать
|
||||
с виртуальными дисками Vitastor напрямую из пространства пользователя с помощью клиентской
|
||||
библиотеки, без необходимости отображения дисков в виде блочных устройств. Тот же драйвер
|
||||
позволяет подключать диски в систему через [VDUSE](../usage/qemu.ru.md#vduse).
|
||||
- **vitastor-nbd** — утилита, позволяющая монтировать образы Vitastor в виде блочных устройств
|
||||
с помощью NBD (Network Block Device), на самом деле скорее работающего как "BUSE"
|
||||
(Block Device In Userspace). Модуля ядра Linux для выполнения той же задачи в Vitastor нет
|
||||
(по крайней мере, пока).
|
||||
- **CSI драйвер** — драйвер для подключения Vitastor-образов в виде персистентных томов (PV) Kubernetes.
|
||||
Работает через vitastor-nbd — образы отражаются в виде блочных устройств и монтируются
|
||||
в контейнеры.
|
||||
- **[vitastor-cli](../usage/cli.ru.md)** — утилита командной строки для управления кластером.
|
||||
Позволяет просматривать общее состояние кластера, управлять пулами и образами — то есть
|
||||
создавать, менять и удалять виртуальные диски, их снимки и клоны.
|
||||
- **[Драйвер QEMU](../usage/qemu.ru.md)** — подключаемый модуль QEMU, позволяющий QEMU/KVM
|
||||
виртуальным машинам работать с виртуальными дисками Vitastor напрямую из пространства пользователя
|
||||
с помощью клиентской библиотеки, без необходимости подключения дисков в виде блочных устройств
|
||||
Linux. Если, однако, вы хотите подключать диски в виде блочных устройств, то вы тоже можете
|
||||
сделать это с помощью того же самого драйвера и [VDUSE](../usage/qemu.ru.md#vduse).
|
||||
- **[vitastor-nbd](../usage/nbd.ru.md)** — утилита, позволяющая монтировать образы Vitastor
|
||||
в виде блочных устройств с помощью NBD (Network Block Device), на самом деле скорее работающего
|
||||
как "BUSE" (Block Device In Userspace). Модуля ядра Linux для выполнения той же задачи в
|
||||
Vitastor нет (по крайней мере, пока). NBD — более старый и нерекомендуемый способ подключения
|
||||
дисков — вам следует использовать VDUSE всегда, когда это возможно.
|
||||
- **[CSI драйвер](../installation/kubernetes.ru.md)** — драйвер для подключения Vitastor-образов
|
||||
в виде персистентных томов (PV) Kubernetes. Работает через VDUSE (если доступно) или через
|
||||
NBD — образы отражаются в виде блочных устройств и монтируются в контейнеры.
|
||||
- **Драйвера Proxmox, OpenStack и т.п.** — подключаемые модули для соответствующих систем,
|
||||
позволяющие использовать Vitastor как хранилище в оных.
|
||||
- **vitastor-nfs** — утилита, предоставляющая файловый доступ к образам в кластере Vitastor
|
||||
по протоколу NFS 3.0. Предназначена для гипервизоров, не основанных на QEMU и Linux, но при
|
||||
этом поддерживающих NFS.
|
||||
- **[vitastor-nfs](../usage/nfs.ru.md)** — NFS 3.0 сервер, предоставляющий два варианта файловой системы:
|
||||
первая — упрощённая для файлового доступа к блочным образам (для не-QEMU гипервизоров, поддерживающих NFS),
|
||||
вторая — VitastorFS, полноценная кластерная POSIX ФС. Оба варианта поддерживают параллельный
|
||||
доступ с нескольких vitastor-nfs серверов. На самом деле можно вообще не выделять
|
||||
отдельные NFS-серверы, а вместо этого использовать команду vitastor-nfs mount, запускающую
|
||||
NFS-сервер прямо на клиентской машине и монтирующую ФС локально.
|
||||
- **[Драйвер fio](../usage/fio.ru.md)** — подключаемый модуль для утилиты тестирования
|
||||
производительности дисков fio, позволяющий тестировать Vitastor-кластеры.
|
||||
- **vitastor-kv** — клиент для key-value базы данных, работающей поверх разделяемого блочного
|
||||
образа (обычного блочного образа vitastor). Метаданные VitastorFS хранятся именно в vitastor-kv.
|
||||
|
||||
## Дополнительные утилиты
|
||||
|
||||
- **vitastor-disk** — утилита для разметки дисков под Vitastor OSD. С её помощью можно
|
||||
создавать, удалять, менять размеры или перемещать разделы OSD.
|
||||
|
||||
## Общий процесс записи и чтения
|
||||
|
||||
|
|
|
@ -224,6 +224,10 @@ int main(int argc, char *argv[])
|
|||
cmd.push_back((char*)"dump-journal");
|
||||
aliased = true;
|
||||
}
|
||||
else if (!strcmp(exe_name, "vitastor-disk-test"))
|
||||
{
|
||||
self.test_mode = true;
|
||||
}
|
||||
for (int i = 1; i < argc; i++)
|
||||
{
|
||||
if (!strcmp(argv[i], "--all"))
|
||||
|
@ -318,6 +322,7 @@ int main(int argc, char *argv[])
|
|||
// First argument is an OSD device - take metadata layout parameters from it
|
||||
if (self.dump_load_check_superblock(self.new_journal_device))
|
||||
return 1;
|
||||
self.new_journal_device = self.dsk.journal_device;
|
||||
self.new_journal_offset = self.dsk.journal_offset;
|
||||
self.new_journal_len = self.dsk.journal_len;
|
||||
}
|
||||
|
@ -383,6 +388,7 @@ int main(int argc, char *argv[])
|
|||
// First argument is an OSD device - take metadata layout parameters from it
|
||||
if (self.dump_load_check_superblock(self.new_meta_device))
|
||||
return 1;
|
||||
self.new_meta_device = self.dsk.meta_device;
|
||||
self.new_meta_offset = self.dsk.meta_offset;
|
||||
self.new_meta_len = self.dsk.meta_len;
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ struct disk_tool_t
|
|||
/**** Parameters ****/
|
||||
|
||||
std::map<std::string, std::string> options;
|
||||
bool test_mode = false;
|
||||
bool all, json, now;
|
||||
bool dump_with_blocks, dump_with_data;
|
||||
blockstore_disk_t dsk;
|
||||
|
@ -128,6 +129,7 @@ struct disk_tool_t
|
|||
|
||||
int prepare_one(std::map<std::string, std::string> options, int is_hdd = -1);
|
||||
int check_existing_partition(const std::string & dev);
|
||||
int fix_partition_type(const std::string & dev_by_uuid);
|
||||
int prepare(std::vector<std::string> devices);
|
||||
std::vector<vitastor_dev_info_t> collect_devices(const std::vector<std::string> & devices);
|
||||
json11::Json add_partitions(vitastor_dev_info_t & devinfo, std::vector<std::string> sizes);
|
||||
|
@ -149,6 +151,6 @@ int write_zero(int fd, uint64_t offset, uint64_t size);
|
|||
json11::Json read_parttable(std::string dev);
|
||||
uint64_t dev_size_from_parttable(json11::Json pt);
|
||||
uint64_t free_from_parttable(json11::Json pt);
|
||||
int fix_partition_type(std::string dev_by_uuid);
|
||||
int fix_partition_type_uuid(std::string dev_by_uuid, const std::string & type_uuid);
|
||||
std::string csum_type_str(uint32_t data_csum_type);
|
||||
uint32_t csum_type_from_str(std::string data_csum_type);
|
||||
|
|
|
@ -159,7 +159,11 @@ int disk_tool_t::prepare_one(std::map<std::string, std::string> options, int is_
|
|||
return 1;
|
||||
}
|
||||
std::string osd_num_str;
|
||||
if (shell_exec({ "vitastor-cli", "alloc-osd" }, "", &osd_num_str, NULL) != 0)
|
||||
if (test_mode && options.find("osd_num") != options.end())
|
||||
{
|
||||
osd_num_str = options["osd_num"];
|
||||
}
|
||||
else if (shell_exec({ "vitastor-cli", "alloc-osd" }, "", &osd_num_str, NULL) != 0)
|
||||
{
|
||||
dsk.close_all();
|
||||
return 1;
|
||||
|
@ -199,10 +203,13 @@ int disk_tool_t::prepare_one(std::map<std::string, std::string> options, int is_
|
|||
if (sep_j)
|
||||
desc += (sep_m ? " and journal on " : " with journal on ") + realpath_str(options["journal_device"]);
|
||||
fprintf(stderr, "Initialized OSD %ju on %s\n", osd_num, desc.c_str());
|
||||
if (shell_exec({ "systemctl", "enable", "--now", "vitastor-osd@"+std::to_string(osd_num) }, "", NULL, NULL) != 0)
|
||||
if (!test_mode || options.find("no_init") == options.end())
|
||||
{
|
||||
fprintf(stderr, "Failed to enable systemd unit vitastor-osd@%ju\n", osd_num);
|
||||
return 1;
|
||||
if (shell_exec({ "systemctl", "enable", "--now", "vitastor-osd@"+std::to_string(osd_num) }, "", NULL, NULL) != 0)
|
||||
{
|
||||
fprintf(stderr, "Failed to enable systemd unit vitastor-osd@%ju\n", osd_num);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
@ -229,6 +236,16 @@ int disk_tool_t::check_existing_partition(const std::string & dev)
|
|||
return 0;
|
||||
}
|
||||
|
||||
int disk_tool_t::fix_partition_type(const std::string & dev)
|
||||
{
|
||||
std::string type_uuid = VITASTOR_PART_TYPE;
|
||||
if (test_mode && options.find("part_type_uuid") != options.end())
|
||||
{
|
||||
type_uuid = options["part_type_uuid"];
|
||||
}
|
||||
return fix_partition_type_uuid(dev, type_uuid);
|
||||
}
|
||||
|
||||
std::vector<vitastor_dev_info_t> disk_tool_t::collect_devices(const std::vector<std::string> & devices)
|
||||
{
|
||||
std::vector<vitastor_dev_info_t> devinfo;
|
||||
|
|
|
@ -343,7 +343,7 @@ uint64_t free_from_parttable(json11::Json pt)
|
|||
return free;
|
||||
}
|
||||
|
||||
int fix_partition_type(std::string dev_by_uuid)
|
||||
int fix_partition_type_uuid(std::string dev_by_uuid, const std::string & type_uuid)
|
||||
{
|
||||
auto uuid = strtolower(dev_by_uuid.substr(dev_by_uuid.rfind('/')+1));
|
||||
std::string parent_dev = get_parent_device(realpath_str(dev_by_uuid, false));
|
||||
|
@ -356,7 +356,7 @@ int fix_partition_type(std::string dev_by_uuid)
|
|||
for (const auto & part: pt["partitions"].array_items())
|
||||
{
|
||||
bool this_part = (strtolower(part["uuid"].string_value()) == uuid);
|
||||
if (this_part && strtolower(part["type"].string_value()) == "e7009fac-a5a1-4d72-af72-53de13059903")
|
||||
if (this_part && strtolower(part["type"].string_value()) == type_uuid)
|
||||
{
|
||||
// Already correct type
|
||||
return 0;
|
||||
|
@ -369,7 +369,7 @@ int fix_partition_type(std::string dev_by_uuid)
|
|||
{
|
||||
script += (first ? "" : ", ")+kv.first+"="+
|
||||
(kv.first == "type" && this_part
|
||||
? "e7009fac-a5a1-4d72-af72-53de13059903"
|
||||
? type_uuid
|
||||
: (kv.second.is_string() ? kv.second.string_value() : kv.second.dump()));
|
||||
first = false;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue