Модель 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)
└──▶ broadcastscallобращение к актору, с которого начинается ход. Сначала запрос попадает в mailbox. Затем выдаётся lease. turnодин ход обработки, который выполняется как одна транзакция и отличается от call тем, что call лишь запускает ход, а turn фиксирует его результат. Внутри одного хода одна транзакция записывает state, remindersнапоминания, которые срабатывают в назначенный срок, effects (outboxисходящие эффекты, которые нужно доставить во внешний мир) и broadcastsшироковещательные сообщения, которые могут расходиться с записью, за которой они следовали.
Если два запроса одновременно вызывают reserve из двух процессов, оба входят в mailbox для event-42, а среда выполнения фиксирует по одному ходу за раз, поэтому счётчик никогда не опускается ниже нуля. Следующий ход начинается с нового вызова, который снова попадает в mailbox и проходит ту же цепочку.
Зачем аренда «fenced»
Аренда помечена как fencedправо записи привязано к конкретной выдаче аренды, идентифицируемой по id. Каждый ход выполняется только от имени действующей аренды, и среда выполнения фиксирует ходы по одному. Если воркер «замедлился» и попытается вернуться после того, как его аренда была заменена, его запись не будет принята: привязка к прежней выдаче уже недействительна. Именно так ограждение защищает от возврата замедлившегося воркера — его устаревшее право записи не совпадает с текущей выдачей, и ход не проходит.
Одна транзакция на ход
В одной транзакции хода фиксируются четыре сущности: 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 с одним продакшен-приложением и без стороннего продакшен-использования — это стоит учитывать при выборе.