用于 PostgreSQL 的开源 Durable Objects
Open Source Durable Objects for Postgres

原始链接: https://solidobjects.dev/blog/introducing-solidobjects

Solid Objects 是一个提供“持久对象”(Durable Objects)模型的新库,具备有状态、基于身份及序列化执行的特性,且没有厂商锁定、定价不可控或基础设施复杂等弊端。 虽然 Cloudflare Durable Objects 为管理状态提供了一种强大的架构,但它要求将逻辑迁移至其专属平台。其他自托管方案往往会引入繁琐的守护进程或额外的依赖。Solid Objects 通过直接在你现有的 SQLite、PostgreSQL 或 MySQL 数据库中实现相同的基于 Actor 的模型,解决了上述问题。 通过利用数据库事务,Solid Objects 确保了每个对象(或 Actor)都拥有一个有序的信箱、持久化的状态以及可靠的提醒机制。它用单一的原子事务流程取代了行锁、Redis 队列和定时任务扫描器等复杂的碎片化系统。由于它直接集成在数据库中,无需管理额外的账户或代理,且数据保持可移植性。 该库目前处于 1.0 版本之前,专为那些既希望获得持久化、有状态编程带来的便利,又不愿牺牲对基础设施的控制权或承担失控云成本的开发者而设计。它能与 Node.js 和 Rails 等现有技术栈无缝协作,为处理重状态的应用程序逻辑提供了一条更简化的路径。

Hacker News | 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 | 登录 **面向 Postgres 的开源持久化对象 (solidobjects.dev)** 7 点,由 cardmagic 发布于 2 小时前 | 隐藏 | 往期 | 收藏 | 1 条评论 | 帮助 **cardmagic 1 小时前 [–]** 我是作者。如果你运行额外的守护进程,celld 和 workerd 可以为你提供持久化对象(Durable Objects)。这是一个你的应用程序可以直接加载的库,状态存储在你已有的 SQL 数据库中(支持 Postgres、MySQL 和 SQLite)。无需守护进程,无需新的基础设施和监控,无需代理,也无需厂商账户。 目前处于 1.0 版本之前,采用 MIT 协议,支持 TypeScript、浏览器端(通过 WASM SQLite)以及 Ruby。欢迎随时提问。 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

Durable Objects is a good model. One object, one identity, one call at a time, and state that outlives the request. Getting that model has meant taking on something else.

  • Lock-in. The model runs on one vendor. Leaving means rewriting the part that made it worth adopting.
  • Pricing you cannot predict. Every operation is metered. One runaway alarm loop billed a pre-launch developer $34,000 in eight days, with no users and no warning.
  • A daemon to run. Self-hosted alternatives like celld trade the vendor for a process on every node, plus a bucket to replicate into.
  • One more stateful system. Something else to back up, monitor, and restore.

Solid Objects gives you the same model as a library. Each object has an identity, durable state, and an ordered mailbox, and all of it lives in the SQLite, PostgreSQL, or MySQL database you already run. No daemon, no broker, no account.

I shipped the first version two days before Shopify published the same architecture for inventory reservations. Same conclusion, reached separately.

Why I built this

An app I maintain ran a cron job every five minutes. Each run loaded every active account in the database to check whether any were due to shut down. One week of that came to 2,014 runs and 37 minutes of queue time. It turned up 8 whole accounts.

Elsewhere in the same app, a scheduled launch lived as one key per target in a key value store. A job scanned all of them every half hour, recovered the target by parsing the key with a regular expression, and still ran up to thirty minutes late. The controllers enqueued a second, delayed copy of that job to cover the gap. Each piece had been added to cover for the one before it.

I took that for a local failing until I read Brian Chesky answering a thread titled "What happens when a host cancels with Airbnb?" It hadn't been a cancellation. "The host did not cancel, we double-booked." A company whose entire product is reservations had shipped the bug I was building scaffolding against.

That left two options and I disliked both. Move onto Cloudflare Durable Objects, which is the right model, and hand over the state, the bill, and the ability to leave. Or keep the sweeps.

The third option was already in front of me. The deadline lived in the database. I moved the schedule there too, so each entity arms one reminder when it's own setting changes and wakes itself at the right time. I ran the old sweep beside it until the two agreed in production, then deleted the cron entry.

And then Shopify published the same move: inventory reservations out of Redis and into MySQL, one row per unit, claimed with SKIP LOCKED. Reading it was vindication of a sort. This is not an old problem being rediscovered. It is hitting people now.

What it replaces

Doing this by hand starts with a row lock, then a Redis lock once the row lock cannot span two requests. The expiry needs a delayed job, which needs an expires_at column and a sweeper once you find it dies with the process. Then retry code, and a broadcast that may disagree with the write it followed.

Six pieces that all have to agree. Solid Objects replaces them with one object.

What works today

ticket-sale.ts

import { Actor } from "solid-objects"

class TicketSale extends Actor {
  static actorType = "TicketSale"
  remaining = 100

  reserve({ buyer }) {
    if (this.remaining === 0) return false
    this.remaining -= 1
    this.schedule({ at: "10 minutes", key: `release:${buyer}` })
    return true
  }

  release({ buyer }) {
    this.remaining += 1
  }
}

await TicketSale.ref("event-42").reserve({ buyer: "Ada" })

Two requests can call reserve at the same instant, from two processes. Both enter the mailbox for event-42, and the runtime commits one turn at a time, so the count never drops below zero. The ten-minute release is stored as a row, so it still fires after a deploy.

A two-player Magic table. Alice and Bob each hold a seat with a life total, a
              library count and cards laid out on their own battlefield.
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. Live at shuffleupandplay.com.

How a turn works

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

Each identity gets an ordered mailbox and a fenced lease. One process wins the lease and runs the turn, then state, reminders, effects, and broadcasts commit together. A worker that lost its lease can't commit. Redis is optional and only shortens wake-up latency.

What it costs

On a laptop with SQLite, a durable call from enqueue to committed completion runs about 2.6 ms at p50 inside one process. Across two processes with polling alone it waits about a second, which is why PostgreSQL notifications and an optional Redis wake-up exist.

The harness and the caveats are in docs/benchmarks.md. These are developer-laptop measurements. They don't predict application capacity.

Install

Terminal

# Node 24+
npm install solid-objects
npx solid-objects quickstart --yes

# Rails 7.1+
bundle add solid_objects
bin/rails solid_objects:install

No daemon, no broker, no account. The install adds tables to your database, the way Solid Queue and Solid Cache do. Views update through reactive ERB and Turbo Streams on Rails, or committed projections on Node, and a browser runtime on SQLite WASM replays offline writes back to a server.

What it is not

There's no edge placement and no routing between regions, and no transaction spanning two identities. Delivery is ordered and at least once, so an external effect has to be idempotent. There is no exactly-once delivery. It is pre-1.0.

It runs in production in one application with more than 100,000 users. There is a demo at shuffleupandplay.com and no third-party production use yet. If your whole invariant fits inside one request, use a transaction.

Next up

Reproducible benchmarks, more soak time on PostgreSQL and MySQL, then 1.0.

← All posts · RSS

联系我们 contact @ memedata.com