コンテンツにスキップ

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")

アーカイブの登録では 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();
});
{
"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 NULL
ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100

FOR UPDATE SKIP LOCKED のおかげで、掃き出しは同時に何本走ってもよいです (タイマーで動く API と、手で叩く pnpm run fga:drain)。同じ行を 2 度処理する ことも、互いを待たせることもありません。

配送は at-least-once です。行は OpenFGA が受け入れるまで残ります。もう一方の 道 —— 送信時に消す —— では、書き込みと更新の間にプロセスが死ぬたびに tuple を 落とすことになり、欠けた tuple は誰かが不当に拒まれるまで目に見えません。

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 を伴うためです。配送済みの行がいくら積もっても、 未配送を引く速さは変わりません。