Как функции без состояния превратились в акторы: Durable Objects, celld, Rivet
ради одной комнаты чата
Состояние общее. Любая копия функции может его менять. Согласованность: ваша забота.
У каждой сущности свой маленький сервер. Состояние принадлежит только ему.
У этой идеи есть имя, и ей пятьдесят лет: модель акторов.
Получив сообщение, актор может:
Общей памяти нет. Блокировок нет.
| Запрос А | Запрос Б | в базе |
|---|---|---|
| читает: мест 1 | 1 | |
| читает: мест 1 | 1 | |
| пишет: мест 0 | 0 | |
| пишет: мест 0 | 0 | |
| Продано два билета на одно место | ||
| Очередь | Актор | в памяти |
|---|---|---|
| А, Б | берёт А: мест 1 → 0 | 0 |
| Б | отвечает А: билет ваш | 0 |
| берёт Б: мест 0 | 0 | |
| отвечает Б: мест нет | 0 | |
| Один билет. Без блокировок и транзакций | ||
«Комната general», «Пользователь 42». Создавать и удалять не нужно: обратился, и он есть.
Пришло сообщение: платформа поднимает актор. Тишина: выгружает. Состояние остаётся на диске.
Платформа гарантирует: в мире работает не больше одной копии актора с этим именем.
Масштаб до нуля + память = serverless с состоянием
Обычные поля класса. Живут, пока объект загружен.
Своя база до 10 ГБ. Запрос к ней: вызов функции, без сети.
Соединения держит платформа. Объект может спать.
«Разбуди меня через 5 секунд». Переживает перезапуск.
$0.15 за миллион. Входящие сообщения WebSocket считаются 20 к 1.
$12.50 за миллион ГБ·с. Объект занимает 128 МБ, значит около $0.0058 за час бодрствования.
$1 за миллион записанных строк, $0.20 за ГБ в месяц. Чтение почти бесплатно.
import { DurableObject } from "cloudflare:workers";
export class Flight extends DurableObject {
// Обычный метод. Вызовы выстраиваются в очередь.
buy() { //!
const sql = this.ctx.storage.sql; //!
const { free } = sql.exec("SELECT free FROM seats").one(); //!
if (free === 0) return "мест нет"; //!
sql.exec("UPDATE seats SET free = free - 1"); //!
return "билет ваш"; //!
} //!
}
export default {
async fetch(request, env) {
const flight = env.FLIGHT.getByName("SU-42"); // адрес по имени
return new Response(await flight.buy());
},
};
// cloudflare.config.ts
export default defineConfig({
worker: {
name: "tickets",
entrypoint: "src/index.js",
env: {
FLIGHT: bindings.durableObject({
worker: "tickets",
exportName: "Flight",
}),
},
exports: {
Flight: exports.durableObject(
{ storage: "sqlite" }),
},
},
});
cf deploy и актор работает в 300+ городах. Базу, очередь и кластер никто не настраивал.
Те самые пять сервисов стали одним классом на 60 строк
export class Room extends DurableObject {
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server); // сокет переживёт сон актора //!
server.serializeAttachment({ name }); // и это тоже
server.send(JSON.stringify({ type: "history", history: this.last(50) }));
return new Response(null, { status: 101, webSocket: client });
}
// Платформа будит актор на каждое сообщение
async webSocketMessage(ws, raw) { //!
const { name } = ws.deserializeAttachment();
const { text } = JSON.parse(raw);
this.ctx.storage.sql.exec(
"INSERT INTO messages(at, name, text) VALUES (?, ?, ?)",
Date.now(), name, text);
for (const peer of this.ctx.getWebSockets()) // все, кто в комнате //!
peer.send(JSON.stringify({ type: "msg", name, text }));
}
}
examples/chat/src/index.js, сокращено
Сервер работает круглые сутки. Проверенная схема, сайт живёт много лет.
export class City extends DurableObject {
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
if (this.snapshot) server.send(JSON.stringify(this.snapshot));
// Первый зритель заводит будильник
if ((await this.ctx.storage.getAlarm()) === null) //!
await this.ctx.storage.setAlarm(Date.now() + 50); //!
return new Response(null, { status: 101, webSocket: client });
}
async alarm() {
const viewers = this.ctx.getWebSockets();
if (viewers.length === 0) return; // все ушли: засыпаем //!
const res = await fetch("https://rnd.don.su/get_bus", {
method: "POST", body: JSON.stringify({ bus: this.routes }) });
this.snapshot = compact(await res.json()); // 55 КБ → 10 КБ
const frame = JSON.stringify(this.snapshot);
for (const ws of viewers) ws.send(frame);
await this.ctx.storage.setAlarm(Date.now() + 5000); //!
}
}
| машин на линии | ~210 |
| ответ источника | 55 КБ |
| кадр зрителю | 10 КБ |
| один опрос | ~250 мс |
Счёт идёт только за эти 250 мс из каждых пяти секунд: 5% времени.
| Что нужно агенту | Что даёт актор |
|---|---|
| Помнить диалог и заметки | своя SQLite |
| Работать минутами, а не секундами | живёт, пока есть работа |
| Показывать ход работы в браузере | WebSocket из того же объекта |
| Проснуться через час и продолжить | будильник |
| Не потерять работу при сбое | контрольные точки на диске |
| Не мешать другим пользователям | один диалог: один актор |
Pi это минимальный каркас агента: цикл «модель → инструменты → модель», очередь входящих, повторы, восстановление.
PiHarness запускает его внутри обычного Durable Object. Статус: экспериментальный.
export class Assistant extends DurableObject {
ai = createAI({ binding: this.env.AI });
registry = createRegistry();
harness = new PiHarness({ //!
harness: ({ storage, context, onWake }) => {
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(storage, // SQLite актора
{ models, registry: this.registry, onWake }, context);
},
defaults: { model: this.ai("@cf/google/gemma-4-26b-a4b-it") },
});
lifecycle = Lifecycle.install(this).use(this.harness); //!
constructor(ctx, env) {
super(ctx, env);
this.registry.install({ name: "talk-demo", tools: [{
name: "save_note",
description: "Сохранить заметку в долговременную память.",
parameters: obj({ text: str() }),
async execute({ text }) { //!
ctx.storage.sql.exec("INSERT INTO notes(text) VALUES (?)", text);
return { output: [{ type: "text", text: "сохранено" }] };
},
}]});
}
}
Инструмент пишет в ту же базу, где лежит сам диалог. Ни сети, ни отдельного хранилища памяти.
Внутри актора всё согласовано. Между акторами и внешними сервисами общей транзакции нет.
export class BuyTicket extends WorkflowEntrypoint {
async run(event, step) {
const { flight, user, card } = event.payload;
await step.do("занять место",
() => this.env.FLIGHT.getByName(flight).hold(user),
{ rollback: () => this.env.FLIGHT.getByName(flight).release(user) }); //!
const { chargeId } = await step.do("списать деньги",
{ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
() => bank.charge(card, 4200),
{ rollback: ({ output }) => bank.refund(output.chargeId) }); //!
await step.do("выпустить билет",
() => this.env.PASSENGER.getByName(user).issue(flight, chargeId));
await step.sleep("перед письмом", "1 minute"); // хоть на месяц
await step.do("отправить письмо", () => mail.send(user, flight));
}
}
Результат каждого шага сохраняется. После сбоя выполнение продолжится с первого незавершённого.
Один дирижёр знает весь сценарий. Так устроен Workflows, и так же можно написать актор-координатор с будильником.
Дирижёра нет. Каждый участник слушает события и решает сам. Гибче, но сценарий размазан по сервисам.
Сеть повторяет запросы. Каждой операции дают идентификатор, и повтор с тем же ключом не выполняется дважды. В Pi это operationId.
«Записать в базу и отправить событие» атомарно: событие сначала пишут в свою же базу, а будильник досылает. Актору это даётся даром.
Код как обычная функция, но каждый шаг сохраняется. Workflows, Temporal, Rivet Workflows, контрольные точки Pi.
Хранить не «мест 0», а «А купил, Б отказано». Своя SQLite в акторе делает это дешёвым: история всегда рядом.
# тот же src/, тот же public/ celld dev --port 19801 # два узла, общий S3-бакет celld deploy . --bucket s3://cells celld --bucket s3://cells --listen 0.0.0.0:8080
| Что измерил | Результат |
|---|---|
| Холодный старт комнаты | 60–70 мс |
| Сообщение с записью в SQLite | 12–20 мс |
| Сообщение без записи | < 1 мс |
| 1000 комнат в памяти | ~390 МБ |
kill -9 узла: комната на соседнем | 9–10 с |
Подвохи: объект не засыпает, пока не задан CELLD_IDLE_EVICT_S. Между узлами нет TLS, нужна приватная сеть. Workers AI нет.
import { actor, event, setup } from "rivetkit";
import { db } from "rivetkit/db";
export const room = actor({
db: db({ onMigrate: (db) => db.execute(
"CREATE TABLE IF NOT EXISTS messages(at, name, text)") }),
events: { msg: event<Msg>() }, // типы до клиента //!
onConnect: (c, conn) => {
c.broadcast("join", { online: c.conns.size });
},
actions: {
say: async (c, text: string) => { //!
const m = { at: Date.now(), name: nameOf(c), text };
await c.db.execute(
"INSERT INTO messages VALUES (?, ?, ?)", m.at, m.name, m.text);
c.broadcast("msg", m); //!
},
},
});
export const registry = setup({ use: { room } });
lab/rivet/app/index.ts, сокращено. RivetKit 3.0.7, Apache 2.0
kill -9 комната вернулась через 46 с| Durable Objects | celld | Rivet | |
|---|---|---|---|
| Где работает | сеть Cloudflare | ваши серверы + S3-бакет | ваши серверы или Rivet Cloud |
| API | эталон | тот же, код без правок | свой: действия и события |
| Сон с открытым сокетом | да | да, если включить | нет |
| Отказ узла (мой стенд) | забота платформы | 9–10 с | 46 с |
| Платите за | запросы и время работы | серверы и бакет | узлы и план |
| Зрелость | в продакшене с 2021 | бета 0.6 | 3.x, быстро меняется |
| Когда брать | по умолчанию | нужен свой контур, а код уже под Cloudflare | нужен свой контур и свобода от API Cloudflare |
«Топ-100 комнат за неделю», отчёты, поиск. Данные разложены по миллиону маленьких баз. Нужна отдельная аналитическая копия.
Актор однопоточный: мягкий предел около 1000 запросов в секунду. «Глобальный счётчик всего» станет узким местом.
128 МБ памяти и один поток. Видео, большие модели, сборки: это контейнеры, а актор ими управляет.
Её нет. Либо перекроите границы так, чтобы хватало одного, либо saga со всеми её оговорками.
Спросите себя: что должно меняться вместе и по очереди?
| Задача | Актор |
|---|---|
| Чат | комната |
| Совместный документ | документ |
| Игра | матч |
| AI-агент | диалог |
| Корзина, лимиты, квоты | пользователь |
| Карта транспорта | город |
Один актор на всё приложение: очередь, предел в 1000 запросов в секунду.
Актор на каждое сообщение: всё становится распределённой транзакцией.
Большинство операций задевают ровно один актор.
Вопросы можно писать сюда:
комната #questions того самого чата