HN 日本語サマリー

← 一覧へ戻る
プログラミング

Postgres向けオープンソースDurable Objects

Open Source Durable Objects for Postgres (solidobjects.dev)

40 pointsby cardmagic15 コメント

要約

Solid Objectsは、Cloudflare Durable Objectsと同様のモデルを、PostgreSQL、SQLite、MySQLなどの既存データベース上でライブラリとして提供するオープンソースプロジェクトです。これにより、ベンダーロックインや予測不能な課金、複雑なインフラ管理なしに、ステートフルなオブジェクトを効率的に扱えるようになります。本プロジェクトは、データベースの行ロックやRedisロック、遅延ジョブなどの複雑な代替手段を置き換えることを目指しています。

全文翻訳

Postgres向けオープンソースのDurable Objects

Durable Objectsは優れたモデルです。1つのオブジェクト、1つのID、1度に1つの呼び出し、そしてリクエストを超えて存続する状態。このモデルを実現するには、別のものを引き受ける必要がありました。

  • ロックイン。 このモデルは特定のベンダー上で実行されます。移行するには、採用する価値があった部分を書き直す必要があります。
  • 予測不能な料金。 すべての操作が計測されます。暴走したアラームループが、ユーザーも警告もなく、8日間で開発者に34,000ドルを請求した例があります。
  • 実行するデーモン。 celldのようなセルフホスト型代替手段は、ベンダーの代わりに各ノード上のプロセスと、レプリケートするためのバケットを必要とします。
  • もう一つのステートフルシステム。 バックアップ、監視、復元が必要なものが増えます。

Solid Objectsは、同じモデルをライブラリとして提供します。各オブジェクトはID、永続的な状態、順序付けられたメールボックスを持ち、それらすべてが既に実行しているSQLite、PostgreSQL、またはMySQLデータベース内に存在します。個別のサービス、ブローカー、アカウントは不要です。

私はShopifyが在庫予約のために同じアーキテクチャを発表する2日前に最初のバージョンをリリースしました。別々に同じ結論に達しました。

なぜこれを作ったのか

私がメンテナンスしているアプリでは、5分ごとにcronジョブが実行されていました。各実行で、データベース内のすべての有効なアカウントをロードして、シャットダウンが必要なアカウントがないかチェックしていました。ある週には2,014回の実行と37分のキュー時間が発生しました。結果として見つかったアカウントはわずか8件でした。

同じアプリの別の場所では、スケジュールされたローンチがキーバリューストア内のターゲットごとの1つのキーとして存在していました。ジョブが30分ごとにそれらすべてをスキャンし、正規表現でキーを解析してターゲットを復元していましたが、それでも最大30分遅延していました。コントローラーは、そのギャップを埋めるために、そのジョブの遅延コピーをエンキューしていました。各部分は、その前の部分を補うために追加されていました。

これをローカルの不具合だと考えていましたが、Brian Cheskyが「ホストがAirbnbでキャンセルした場合どうなるか?」というスレッドに答えているのを読みました。それはキャンセルではありませんでした。"ホストはキャンセルせず、ダブルブッキングしました。"製品全体が予約である会社が、私が足場を構築していたバグをリリースしていたのです。

そうなると、2つの選択肢がありましたが、どちらも好きではありませんでした。正しいモデルであるCloudflare Durable Objectsに移行し、状態、請求、そして離れる能力を委ねるか。あるいは、スイープを続けるか。

3番目の選択肢は既に目の前にありました。締め切りはデータベースにありました。スケジュールもそこに移動させたので、各エンティティは設定が変更されると1つのリマインダーをセットし、適切な時間に自分自身を起動します。私は古いスイープを並行して実行し、本番環境で両者が一致するのを確認してから、cronエントリを削除しました。

そしてShopifyが同じ動きを発表しました:RedisからMySQLへの在庫予約、ユニットごとに1行、SKIP LOCKEDで取得。それを読むのはある種の正当化でした。これは再発見された古い問題ではありません。今、人々が直面している問題です。

何を置き換えるか

これを手動で行う場合、まず行ロックから始まり、行ロックが2つのリクエストにまたがれない場合はRedisロックが続きます。有効期限には遅延ジョブが必要で、expires_atカラムと、プロセスが終了したときにそれを発見するスイーパーが必要です。その後、リトライコードと、それに続く書き込みと矛盾する可能性のあるブロードキャストが必要です。

すべてが一致しなければならない6つの要素。Solid Objectsはそれらを1つのオブジェクトに置き換えます。

現在動作するもの

ticket-sale.ts

import { Actor } from "solid-objects"

const HOLD_MILLISECONDS = 10 * 60 * 1000

export class TicketSale extends Actor {
  static override readonly actorType = "TicketSale"

  available = 1
  holds: Record<string, number> = {}

  hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
    if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
      return { held: false, available: this.available }
    }

    this.available -= 1
    this.holds = { ...this.holds, [buyer]: Date.now() }
    this.schedule({ at: new Date(Date.now() + HOLD_MILLISECONDS), key: buyer }).expire({ buyer })
    return { held: true, available: this.available }
  }

  expire({ buyer }: { buyer: string }): number {
    if (!Object.hasOwn(this.holds, buyer)) return this.available

    const remainingHolds = { ...this.holds }
    delete remainingHolds[buyer]
    this.holds = remainingHolds
    this.available += 1
    return this.available
  }
}

await TicketSale.ref("event-42").hold({ buyer: "ada" })

2つのリクエストが同時にholdを呼び出すことができます。これは2つのプロセスから行われます。両方ともevent-42のメールボックスに入り、ランタイムは一度に1つのターンをコミットするため、カウントがゼロを下回ることはありません。10分間の有効期限は行として保存されるため、デプロイ後も有効期限が切れます。

これはテストされた例のクラスです。完全なファイルにはランタイム設定も含まれています。

[画像: 2人用マジックのテーブル。アリスとボブはそれぞれ、ライフ総数、ライブラリ数、そして自分のフィールドに置かれたカードを持つ席に座っています。]

1つのテーブル、1つのアクター。すべてのドロー、タップ、移動は同じメールボックスに入り、順序通りにコミットされるため、2つの画面が矛盾することはありません。shuffleupandplay.comでライブデモをご覧いただけます。

ターンはどのように機能するか

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

各IDは順序付けられたメールボックスとフェンスされたリースを取得します。1つのプロセスがリースを獲得し、ターンを実行します。その後、状態、リマインダー、エフェクト、ブロードキャストがまとめてコミットされます。リースを失ったワーカーはコミットできません。Redisはオプションであり、ウェイクアップの遅延を短縮するだけです。

コスト

SQLiteを搭載したラップトップでは、エンキューからコミット完了までの耐久性のある呼び出しは、単一プロセス内でのp50で約2.6ミリ秒です。ポーリングのみを使用した2つのプロセス間では約1秒待機します。これが、PostgreSQL通知とオプションのRedisウェイクアップが存在する理由です。

ハーネスと注意点はdocs/benchmarks.mdにあります。これらは開発者ラップトップでの測定値です。アプリケーションのキャパシティを予測するものではありません。

インストール

ターミナル

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

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

個別のサービス、ブローカー、アカウントは不要です。バックグラウンドワークは、ご自身のアプリのプロセス内で実行されます。インストールにより、Solid QueueやSolid Cacheのように、データベースにテーブルが追加されます。ビューは、RailsではリアクティブなERBとTurbo Streams、Nodeではコミットされたプロジェクション、そしてSQLite WASM上のブラウザランタイムがオフライン書き込みをサーバーにリプレイすることで更新されます。

何ではないか

エッジ配置やリージョン間のルーティング、2つのIDにまたがるトランザクションはありません。配信は順序付けられ、少なくとも1回は行われるため、外部効果は冪等である必要があります。正確に1回の配信はありません。バージョン1.0はAPIに関する約束であり、測定されたスケールに関する主張ではありません。

100,000人以上のユーザーがいる1つのアプリケーションで本番稼働しています。shuffleupandplay.comにはデモがあり、まだサードパーティによる本番利用はありません。もしあなたの不変条件全体が1つのリクエストに収まるなら、トランザクションを使用してください。

次のステップ

更新、2026年10月13日:バージョン1.0がリリースされました。PostgreSQLとMySQLでのより長いソークテストは、まだ作業中です。

← 全ての投稿 · RSS