fga_outbox
OpenFGA へ「送る予定」の tuple を溜める箱です。名前に fga と付きますが
capture-ledger 側のテーブルで、OpenFGA のものではありません。
.addColumn("id", "bigserial", (col) => col.primaryKey()) .addColumn("payload", "jsonb", (col) => col.notNull()) .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)) // 配送されるまで NULL。worker は行を消さない: 処理済みの行は tuple が書かれた // という証拠で、台帳と OpenFGA を突き合わせるときに残っている価値がある。 .addColumn("processed_at", "timestamptz") .addColumn("attempts", "integer", (col) => col.notNull().defaultTo(0)) .addColumn("last_error", "text")なぜ要るのか
Section titled “なぜ要るのか”アーカイブの登録では 2 つのことを書きます。
| 書くもの | 行き先 |
|---|---|
| アーカイブの行 | Postgres(SQL) |
| 関係のタプル | OpenFGA(HTTP API) |
両方にまたがるトランザクションは存在しません。 独立に書くと片方だけ入りえて、 どちらの結果も悪いものです。
- 誰も辿り着けないアーカイブ
- 巻き戻された行を指す権限
そこで tuple の書き込みを「予定」としてアーカイブと同じトランザクションに記録 します。両方入るか、どちらも入らないかです。
return db.transaction().execute(async (trx) => { const inserted = await trx.insertInto("archives").values({ … }).executeTakeFirst(); if (!inserted) return { reason: "already-known" };
await trx.insertInto("fgaOutbox").values({ payload: JSON.stringify({ writes: [ … ] }) }).execute();});payload の中身
Section titled “payload の中身”{ "writes": [ { "user": "capture_job:7f170ae4-…", "object": "archive:3a4cb75c-…", "relation": "parent" }, { "user": "organization:acme", "object": "capture_job:7f170ae4-…", "relation": "parent" } ]}2 本を 1 度に積みます。アーカイブをジョブに繋ぎ、そのジョブを組織に繋ぐ —— この 2 本があって初めて、組織のメンバーがアーカイブに辿り着けます。
pnpm run fga:drain か、API プロセスのタイマーが掃き出します。
SELECT … FROM fga_outbox WHERE processed_at IS NULLORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100FOR UPDATE SKIP LOCKED のおかげで、掃き出しは同時に何本走ってもよいです
(タイマーで動く API と、手で叩く pnpm run fga:drain)。同じ行を 2 度処理する
ことも、互いを待たせることもありません。
配送は at-least-once です。行は OpenFGA が受け入れるまで残ります。もう一方の 道 —— 送信時に消す —— では、書き込みと更新の間にプロセスが死ぬたびに tuple を 落とすことになり、欠けた tuple は誰かが不当に拒まれるまで目に見えません。
処理済みの行を消さない理由
Section titled “処理済みの行を消さない理由”processed_at IS NULL → まだ配送していないprocessed_at IS NOT NULL → 配送済み。行は残るworker は行を消しません。 処理済みの行は「tuple が書かれた」という証拠で、 台帳と OpenFGA を突き合わせるときに残っている価値があります。
fga_outbox_pkey PRIMARY KEY (id)fga_outbox_pending_idx (id) WHERE processed_at IS NULL -- 未配送だけを速く引くfga_outbox_pending_idx が部分索引なのは、掃き出しが必ず
WHERE processed_at IS NULL を伴うためです。配送済みの行がいくら積もっても、
未配送を引く速さは変わりません。