Назад к блогу

Durable Objects поверх Postgres: как SolidObjects приносит stateful-модель Cloudflare в обычную базу

Durable Objects поверх Postgres: как SolidObjects приносит stateful-модель Cloudflare в обычную базу

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

Модель Durable Objects удобна тем, что состояние сущности живёт рядом с вычислением и не расползается по блокировкам, очередям и кэшам. Но она привязывает приложение к одному провайдеру. SolidObjects предлагает ту же механику — упорядоченный почтовый ящик, ограждённую аренду и один транзакционный ход — поверх обычной базы данных. Разберём, из каких шагов состоит ход, что именно фиксируется одной транзакцией и какие ограничения авторы приняли сознательно.

Какую проблему решает SolidObjects

Приложение запускало cron каждые пять минут, и за неделю это дало 2 014 запусков и 37 минут времени в очереди, но нашло всего 8 аккаунтов. В том же приложении отложенный запуск хранился как один ключ на цель в key value store; задание сканировало все ключи каждые полчаса, восстанавливало цель парсингом ключа регулярным выражением и всё равно опаздывало до тридцати минут, а контроллеры ставили в очередь вторую, отложенную копию задания, чтобы закрыть разрыв.

Автор видел два варианта, и оба ему не нравились: перейти на Cloudflare Durable Objects — «which is the right model, and hand over the state, the bill, and the ability to leave» — либо «keep the sweeps». Третий вариант уже был перед ним: срок хранился в базе данных, и он перенёс туда и расписание, так что каждая сущность ставит одно напоминание при изменении своей настройки и сама просыпается в нужное время. Старое прочёсывание он запускал рядом, пока они не совпали в продакшене, после чего удалил запись в cron.

Тот же подход независимо применил Shopify, перенеся резервирование запасов из Redis в MySQL — по одной строке на единицу, с захватом через SKIP LOCKED.

Что именно заменяет SolidObjects

Ручная реализация отложенной блокировки начинается с блокировки строки, затем требуется блокировка Redis, когда блокировка строки не может охватить два запроса. Для истечения срока нужна отложенная задача, которая требует колонки expires_at и «подметальщика» (sweeper), обнаруживаемого после того, как он умирает вместе с процессом. Далее идут код повторных попыток и широковещательное сообщение, которое может расходиться с записью, за которой оно следовало. Всего шесть частей, которые должны согласовываться, — и Solid Objects заменяет их одним объектом.

Жизненный цикл вызова: mailbox, lease, turn

Вызов проходит цепочку call → mailbox → lease → turn, где turn — это одна транзакция:

call ──▶ mailbox ──▶ lease ──▶ turn ──▶ ONE TRANSACTION
         ordered      fenced
         per id       per id
         ├──▶ state
         ├──▶ reminders
         ├──▶ effects (outbox)
         └──▶ broadcasts

call. Сначала запрос попадает в mailbox. Затем выдаётся lease. turn. Внутри одного хода одна транзакция записывает state, reminders, effects (outbox) и broadcasts.

Если два запроса одновременно вызывают reserve из двух процессов, оба входят в mailbox для event-42, а среда выполнения фиксирует по одному ходу за раз, поэтому счётчик никогда не опускается ниже нуля. Следующий ход начинается с нового вызова, который снова попадает в mailbox и проходит ту же цепочку.

Зачем аренда «fenced»

Аренда помечена как fenced. Каждый ход выполняется только от имени действующей аренды, и среда выполнения фиксирует ходы по одному. Если воркер «замедлился» и попытается вернуться после того, как его аренда была заменена, его запись не будет принята: привязка к прежней выдаче уже недействительна. Именно так ограждение защищает от возврата замедлившегося воркера — его устаревшее право записи не совпадает с текущей выдачей, и ход не проходит.

Одна транзакция на ход

В одной транзакции хода фиксируются четыре сущности: state (состояние), reminders (напоминания), effects (outbox — исходящие эффекты) и broadcasts (широковещательные сообщения). Атомарная запись гарантирует, что все они фиксируются вместе как единое целое в рамках одного хода. Один процесс выигрывает аренду и выполняет ход, после чего все четыре сущности коммитятся вместе; воркер, потерявший аренду, зафиксировать результат не может.

Пробуждение воркера и задержки

Redis в этой схеме необязателен и служит только для сокращения задержки пробуждения. Уведомления PostgreSQL и опциональное Redis-пробуждение существуют потому, что при одном только опросе между двумя процессами вызов ждёт около секунды. Внутри одного процесса устойчивый вызов от постановки в очередь до зафиксированного завершения занимает около 2.6 мс на p50.

Пример: резервирование без гонок

Актор объявляется как класс, расширяющий импортированный Actor, а статическое поле actorType задаёт строковый тип актора. Поле экземпляра remaining инициализируется числом 100 и хранит состояние. Метод reserve принимает объект с полем buyer: если мест больше не осталось, резервирование не происходит, иначе счётчик свободных мест уменьшается на единицу, а через this.schedule ставится отложенное действие с параметрами at: "10 minutes" и ключом release:${buyer}. Метод release принимает buyer и возвращает одно место в счётчик. Вызов await TicketSale.ref("event-42").reserve({ buyer: "Ada" }) получает ссылку на актор с идентификатором event-42 и вызывает reserve для покупателя Ada.

this.schedule — это планирование отложенного действия, а ключ release:${buyer} — ключ напоминания.

Два одновременных вызова reserve от двух процессов попадают в один и тот же mailbox для event-42, и среда выполнения фиксирует ходы по одному за раз, поэтому проверка свободных мест и уменьшение счётчика не перемешиваются. Каждый ход выполняется как одна транзакция, включающая состояние, напоминания, эффекты и широковещательные сообщения, поэтому отдельная блокировка строки через SELECT FOR UPDATE не нужна.

Вызов this.schedule({ at: "10 minutes", key: ... }) проходит через mailbox, lease и turn и попадает в ту же единственную транзакцию, что и изменение состояния, поэтому напоминание сохраняется вместе с ним. Напоминание хранится как строка, поэтому оно продолжает срабатывать и после деплоя: при падении процесса до срока оно остаётся зафиксированным и будет обработано при последующем запуске.

Тот же принцип работает для игры: «One table, one actor. Every draw, tap and move from both seats enters the same mailbox and commits in order, so the two screens cannot disagree.» Все действия обоих игроков попадают в один mailbox и фиксируются по порядку, поэтому две панели не могут показать расходящееся состояние.

Требования к окружению

Для Node установка выполняется через npm install solid-objects и запуск npx solid-objects quickstart --yes. Для Rails требуется Rails 7.1+ с bundle add solid_objects и bin/rails solid_objects:install. Установка не требует демона, брокера или аккаунта: «No daemon, no broker, no account.» Вместо этого она добавляет таблицы в вашу базу данных, как Solid Queue и Solid Cache.

Что SolidObjects сознательно не делает

Нет edge placement и маршрутизации между регионами, нет транзакции, охватывающей две идентичности. Доставка упорядоченная и at least once, поэтому внешний эффект должен быть идемпотентным: «There is no exactly-once delivery.» Проект pre-1.0, работает в продакшене в одном приложении, есть демо на shuffleupandplay.com, но стороннего продакшен-использования пока нет. Если весь инвариант укладывается в один запрос, следует использовать транзакцию.

Что стоит накладных расходов

На ноутбуке с SQLite устойчивый вызов от постановки в очередь до зафиксированного завершения занимает около 2.6 мс на p50 внутри одного процесса. При работе через два процесса с одним лишь polling вызов ожидает около секунды — отсюда уведомления PostgreSQL и необязательный Redis для ускорения пробуждения. Измерения сделаны на ноутбуке разработчика и не предсказывают ёмкость приложения; харнесс и оговорки находятся в docs/benchmarks.md.

Сравнение с Cloudflare Durable Objects

Durable Object — это особый вид Cloudflare Worker, который сочетает вычисления с хранилищем. Каждый такой объект имеет глобально уникальное имя, что позволяет адресовать запросы к конкретному объекту из любой точки мира. К объекту привязано долговечное хранилище, сильно согласованное и быстро доступное, поскольку живёт вместе с объектом. Это делает возможными stateful-приложения.

В модели SolidObjects каждый идентификатор получает упорядоченный почтовый ящик и ограждённую аренду. Один процесс выигрывает аренду и выполняет ход, после чего состояние, напоминания, эффекты и широковещательные сообщения фиксируются вместе; процесс, потерявший аренду, зафиксировать изменения не может. Разница в том, что SolidObjects не даёт edge placement и маршрутизации между регионами, зато не требует передачи состояния, счёта и возможности уйти.

Сравнение с celld

celld — альтернативный подход к той же задаче, но с другим набором операционных издержек. Установка выполняется одной командой: curl -fsSL https://celld.dev/install.sh | sh. Локальный object store не требует Docker или облачного бакета: «The command starts one celld node with a local object store, so it needs no Docker or cloud bucket.» Состояние хранится в .celld/dev и переживает перезапуск и изменение конфигурации, но celld не мигрирует его, поэтому объект может сохранить значение, которое новая конфигурация отвергает; для очистки используется celld dev --clean.

Для репликации celld разворачивается на S3-совместимом бакете. Одна нода подтверждает каждую запись через бакет, что стоит одного round trip к хранилищу; вторая нода ускоряет запись, так как запись завершается, как только вторая нода сохранит данные на диск. Диагностика выполняется командой celld diagnose --bucket s3://my-cells-bucket. Жёсткий лимит резидентных ячеек задаётся на каждой загруженной ноде через CELLD_MAX_RESIDENT_CELLS.

Узел с наибольшим числом принадлежащих ему ячеек на единицу веса передаёт не более 32 гибернированных ячеек за снимок тому пиру, который наиболее отстаёт от своей доли. Гибернированная ячейка перемещается одной записью, а её припаркованные гибернируемые WebSockets закрываются с кодом 1012, чтобы клиенты переподключились к новому владельцу. Резидентная ячейка перемещается только после того, как idle eviction переведёт её в гибернацию (CELLD_IDLE_EVICT_S). Управление балансировкой — POST /rebalance/pause на внутреннем слушателе любого узла — приостанавливает флот.

celld поддерживает бэкенды хранения, выбираемые схемой bucket: s3:// (пример с Cloudflare R2 endpoint), gs:// (Google Cloud Storage) и az:// (Azure Blob Storage). Для Azure az:// имя bucket — это контейнер, а AZURE_STORAGE_ACCOUNT_NAME задаёт storage account; требуется ровно одно семейство учётных данных: ключ storage account, managed identity или workload identity. AKS workload identity использует AZURE_AUTHORITY_HOST, AZURE_CLIENT_ID, AZURE_TENANT_ID и AZURE_FEDERATED_TOKEN_FILE, причём authority host должен указывать на публичное облако Azure; для Microsoft Entra identity нужно data-plane разрешение на чтение, запись, листинг и удаление блобов, которое даёт роль Storage Blob Data Contributor. celld отклоняет S3 --endpoint для az:// bucket и игнорирует storage region. Для локального состояния в контейнере создаётся volume celld-state, монтируемый в /var/lib/celld, а CELLD_WATCH=/var/lib/celld/state; стандартные AWS-переменные AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN передаются в контейнер.

Что из этого следует на практике

  • Один ход — одна транзакция. Состояние, напоминания, исходящие эффекты и широковещательные сообщения фиксируются вместе, поэтому расхождение между записью и следующим за ней broadcast в этой модели не возникает: они часть одной транзакции.
  • Упорядочивание и ограждение — два разных механизма. Mailbox задаёт порядок вызовов по идентификатору, аренда привязывает право записи к конкретной выдаче. Без второго замедлившийся воркер мог бы вернуться с устаревшим правом записи; ограждение это отсекает.
  • Задержка зависит от топологии. Внутри одного процесса вызов укладывается в 2.6 мс на p50, между двумя процессами при одном только опросе — около секунды. Уведомления PostgreSQL и необязательный Redis существуют именно для того, чтобы сократить этот разрыв.
  • Доставка «at least once» накладывает требование на внешние эффекты: они должны быть идемпотентными. Exactly-once доставки нет.
  • Границы применимости очерчены явно: нет edge placement и маршрутизации между регионами, нет транзакции, охватывающей две идентичности. Если весь инвариант укладывается в один запрос, транзакция подойдёт лучше.
  • Проект pre-1.0 с одним продакшен-приложением и без стороннего продакшен-использования — это стоит учитывать при выборе.

Источники

Похожее