Zig помог нам перенести данные в Edge. Вот наши впечатления

Удар по шинам на Zig с новым проектом с открытым исходным кодом.

Наша компания является магазином Rust. Мы любим Rust и верим, что Rust будет основным двигателем нашей работы на всю жизнь. Но мы, кучка фанатов производительности, очень внимательно следим за Зигом. Это вызывает чувство простоты, которое я жажду дней C, и comptime, возможность запускать произвольный код во время компиляции, является прямо блестящей идеей.

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

Постановка проблемы

Наш продукт Турсо — это Edge Database. Если вы не знакомы с этой концепцией, все очень просто: если вы развертываете свой код в нескольких географических точках, доступ к вашим данным из центрального местоположения замедлит работу вашего приложения. Вам это может не нравиться, но один гений пару лет назад доказал, что с этим ничего нельзя поделать (я говорю об Эйнштейне, а не о Томе).

Из-за ограничений физического мира единственный способ получить сверхбыстрые запросы к базе данных как в Сан-Франциско, так и в Сиднее — это реплицировать данные в обоих местах. Поддерживать работу базы данных в нескольких местах дорого, а это означает, что для выполнения этой работы вам нужна база данных, которую очень дешево запускать. Вот почему мы используем libSQL, открытый форк SQLite. Добавьте к этому множество механизмов, делающих репликацию простой и легкой, и автоматически направляющих вас к ближайшей реплике, и вы получите пограничную базу данных.

Затраты на хранение

Репликация данных везде требует затрат. Наша зависимость от тонкой и мощной базы данных помогает нам контролировать затраты на вычисления. Но для данных вы мало что можете сделать. Хотите десять реплик? Платите в десять раз больше, чем за хранение.

Это хорошо работает для различных приложений, особенно в Интернете, где объемы данных «невелики». Раньше я помогал проектировать базу данных NoSQL (ScyllaDB), которая работала в масштабе петабайт, поэтому «низкий» и «высокий» всегда относительные. Обоснуем это цифрами: хранение гигабайта данных в быстром хранилище стоит меньше доллара в месяц. Примите 25 центов, чтобы оставить место для всех наценок. Хранение 10 ГБ данных будет стоить 2,50 доллара США за регион. Мы поддерживаем 34 региона, поэтому даже при развертывании во всех наших регионах затраты на хранение все равно составляют 85 долларов в месяц — меньше, чем вы будете платить за Hubspot, Google Workspaces или любой другой инструмент SaaS, от которого зависит ваша компания.

Но даже до того, как мы достигнем уровня петабайт, есть много вариантов использования, которые будут накапливать сотни или даже тысячи ГБ. И хотя у вас могут быть лишние деньги, реальность такова, что вам не нужны все эти данные на Edge. Некоторые из них просто холодные, и вам это не нужно все время. Архитектура, которая использует преимущество периферии при снижении затрат, — это та, которая будет хранить вашу базу данных записей в центральном расположении, а затем реплицировать некоторые из этих данных на периферию.

Решение: pg_turso

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

Это работает так: вы выбираете таблицу (или материализованное представление) в Postgres, которую хотите реплицировать на периферию. Таблицы часто уже являются подмножеством ваших данных, а материализованные представления — это стандартный способ выбора части ваших данных для определенных запросов. Затем наше расширение подключается к логической репликации Postgres и процессу обновления материализованного представления, реплицируя изменения прямо в базу данных Turso.

Мы создали pg_turso с Zig

Первая причина, по которой это имело смысл, заключается в том, что pg_turso — очень автономный и изолированный проект, и ему не нужно делиться кодом с остальной частью нашей базы данных. Нет необходимости переписывать производственный код или даже зависеть от Zig.

Вторая причина заключалась в том, что в дикой природе уже существовал код, написанный на C, который был похож на то, что мы хотели сделать. Если бы мы могли повторно использовать часть этого кода, это было бы победой. Postgres позволяет пользователям предоставлять выходной плагин логического декодирования, который является причудливым названием для ваших собственных процедур репликации. У самой Postgres уже есть пример плагина, с которого можно начать, test_decoding.c.

Zig обеспечивает совместимость C

Zig известен своей бесшовной совместимостью с C. У него даже есть кросс-компилятор для преобразования кода C прямо в Zig. Я никогда раньше не прикасался к Zig (и потому что что-то могло пойти не так), поэтому я просто попробовал:

zig translate-c test_decoding.c

… что совсем не сработало!

Но это было только из-за отсутствия заголовков, и, к моему небольшому удивлению,

zig translate-c -I /usr/include -I ../../src/include test_decoding.c

Он скомпилировался просто отлично, сбрасывая много действительного Zig-кода. Нам еще предстояло поработать, чтобы наше расширение заработало, но это только начало!

Следующим шагом было добавление некоторых определений того, что является модулем postgres. В приведенном выше тестовом коде это было сделано с помощью макросов, которые Zig, к счастью, не поддерживает. (Для пользователей Rust, которые жалуются на макросы Rust… Макросы C — прямо из ада.) Это требовало немного шаблонного кода, но все же управляемо и эргономично для написания.

Как и ожидалось, нам также понадобится несколько определений из интерфейса заголовка postgres.h. Забудьте о генераторах привязок и явных внешних интерфейсах функций: в Zig вы просто вставляете туда @cImport и заканчиваете. Весь код C доступен в изолированном пространстве имен.

Директива @cImport основана на translate-c, что означает, что заголовок транслируется в собственный код Zig во время компиляции. Вот где Зиг сияет. Он просто плавно оборачивает заголовок C в структуру Zig, как если бы это был еще один модуль Zig, и вы можете свободно использовать все константы и функции, как если бы они были родными Zig. Поистине удивительно.

Отладка и кросс-компиляция проходят гладко

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

Преимущество такого поведения заключается в том, что отладка серьезных проблем, которые всегда возникают при написании системного программного обеспечения, становится намного проще: все зависимости C, стандартная библиотека C и стандартная библиотека Zig поставляются в виде кода, который компилируется вместе с ним. с вашим проектом.

Это дает компилятору больше возможностей для оптимизации, встраивания и уменьшения вашего окончательного двоичного файла только до того, что ему нужно, но это также означает, что вы можете сами редактировать код, если столкнетесь с одной из тех необъяснимых проблем, которые могут возникнуть откуда угодно ( как мы).

Другое преимущество заключается в том, что он позволяет Zig проявить себя в кросс-компиляции. В нашей компании, например, одной из причин, побудивших нас написать наш CLI на Go, является то, насколько хорошо он кросс-компилируется для Mac (Apple Silicon и Intel), Linux и даже Windows. Ржавчине и близко не до этого.

translate-c имеет проблемы с неясным кодом C

Каким бы замечательным ни был опыт работы с translate-c, у Зига были проблемы с некоторыми сложными конструкциями макросов. Теперь это больше говорит о макросах C, чем о Zig (я упоминал, насколько чудовищными могут быть макросы C?). Основная проблема заключается в том, что компилятор Zig не всегда способен безопасно угадывать типы. Честно говоря, часто с макросами C люди тоже не могут их угадать, но реальность такова, что мир C полон этих макросов, поэтому ожидайте, что интероперабельность иногда будет давать сбои.

Хорошие стороны Rust здесь

Оценка современных языков, таких как Rust и Zig, должна выходить за рамки определения языка. Экосистема имеет значение.

Процесс сборки Zig элегантен, и это не будет сюрпризом для тех, кто когда-либо писал скрипт build.rs раньше. Zig построен на схожих принципах, и если вы хотите заявить, что ваш код должен быть связан со стандартной библиотекой C и скомпилирован в разделяемую библиотеку, вы просто выражаете все это в Zig.

Еще одна вещь, которой восхищались бы хакеры Rust, — это zig fmt — самоуверенный инструмент для форматирования кода Zig, позволяющий избежать бесконечных споров по поводу стиля кода.

Обработка ошибок — еще один эргономический аспект Zig. Сначала я был очень сбит с толку, когда увидел все catch unreachable идиомы над примерами кода. Но как только я понял это, это обрело смысл. Он также хорошо соответствует концепциям Rust.

Функции могут явно объявлять, могут ли они возвращать ошибки. Если они это сделают, вы можете использовать внутри них оператор try, который концептуально похож на оператор ? в Rust — он возвращается из функции раньше, если что-то не получается:

_ = try std.fmt.bufPrint(stmt_buf[offset..], "null", .{});

Ошибки обрабатываются оператором catch:

send(data.*.url, data.*.auth, json_payload) catch |err| {
  std.debug.print("Failed to replicate: {}\n", .{err});
};

И catch unreachable является концептуальным близнецом Rust unwrap — он прерывает выполнение вашей программы, если произошла ошибка.

const prefix = std.fmt.bufPrint(&stmt_buf, "INSERT INTO {s} ", .{table}) catch unreachable;

я скучаю по РАИИ

Зиг очень уверен в том, что «явное лучше, чем неявное». Как следствие, в нем отсутствуют деструкторы в стиле Rust, и все выделения должны происходить явно. Явное выделение, безусловно, хорошо, но отсутствие деструкторов — мягкое орудие. Подобно Go, Zig предлагает ключевое слово defer, позволяющее программистам создавать подпрограммы завершения работы. Идиоматично писать такой код:

const something = createSomething(allocator);
defer something.deinit();

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

Я, очевидно, вижу обратную сторону этого: иногда вы не заинтересованы в вызове деструкторов, например. если ваша программа использует распределители арен или создает долгоживущие объекты, которые на Rust писать немного больно. Тем не менее, как человек, который все забывает, я скучаю по удобству RAII в Rust по умолчанию.

Экосистема все еще созревает

Zig имеет поддержку HTTP и JSON, встроенную в стандартную библиотеку, что очень удобно, поскольку Turso доступен через HTTP.

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

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

Опыт участия в Zig был действительно замечательным — PR был быстро рассмотрен и принят, а вскоре после этого попал в релиз для разработчиков. Но, в конце концов, центральная проблема с HTTP-заголовками действительно показывает, что язык должен немного повзрослеть, прежде чем мы сможем перевести на него всю нашу компанию.

Вердикт

Общее впечатление было действительно отличным — код Zig выглядит чище, заголовок Postgres C API аккуратно спрятан за интерфейсом Zig, а поддержка стандартной библиотекой HTTP и JSON означает, что нам не нужны никакие внешние зависимости, что имеет свою ценность. .

Несмотря на пару шероховатостей, мы по-прежнему оптимистично смотрим в будущее Zig. Однако это будущее еще не наступило.