「残高カラム」を持たない — FIFO失効・冪等発行・没収を SQLite の1テーブルで成立させる追記型台帳

Ember 編集部 ・ 2026-07-15 ・ 約6分 ・ web-backend

Ember のポイント経済(貢献で発行 → 記事の unlock で焼却 → 12か月で失効)の裏側には、 balance カラムがありません。あるのは ledger events という追記専用の1テーブルだけ 。残高はイベント列の畳み込みで、失効予定も、没収も、すべてこの1テーブルから導出されます。 残高を「状態」ではなく「導出値」にすると何が楽になるか 発行ロットの FIFO 消費と失効 を、実行タイミングに依存せず 決定的 に計算する畳み込みの書き方 全イベントに冪等キーを付けて、 リトライ・重複発火・cron の重複実行を全部ノーガードで受ける 設計 「過去の残高」を正しく答えられないと 残高検査がすり抜ける 、 atTs の罠 不正アカウントの全額没収(slashing)を、特別な状態を持たずに通常イベントとして扱う方法 規模は小さくても「お金っぽいもの」を扱うシステムの設計原則は同じです。ポイント・クレジット・クォータ・インセンティブ — この形の要件を持つ人なら、テーブル定義ごと流用できるはずです。

原則: 追記型・削除不可・全イベントに冪等キー UPDATE も DELETE もしません。間違えたら逆向きの ADJUST を追記する。この不変条件だけで、「いつ・何が・なぜ」の完全な監査証跡が構造から手に入ります。 残高 = ロットの畳み込み 「12か月で失効」を正しくやるには、残高を1つの数字で持つ設計は破綻します。 どの発行分がいつ失効するか を追う必要があるからです。そこで残高は「発行ロットの残量リスト」として毎回導出します。 要点は3つ。 1. 負イベントは FIFO で古いロットから消費する 。ユーザーに最も有利(失効が近い分から使われる)で、説明もしやすい。 2. 消費時に「そのイベントの時点で失効済みのロット」をスキップする 。焼却が起きた瞬間の世界で計算する、ということ。 3. この規則により、結果が 失効スイープの実行タイミングに依存しない 。スイープが1日遅れても、再実行されても、畳み込みの答えは同じです。バッチの実行順序が状態に影響しない台帳は、運用の心配事を劇的に減らします。 atTs の罠: 「過去の残高」を正しく答える 畳み込みには atTs (いつ時点の残高か)があり、 ts <= atTs のイベントだけを畳みます。当たり前に見えて、これを省くと実害が出ます。 シードやバックフィルで過去の日付のイベントを入れるとき、「その時点の残高」で検査しないと、 未来の発行分を原資に過去の焼却が通ってしまう 。Ember でも初期に「まだ存在しないポイント」が残高検査をすり抜ける穴として顕在化し、 atTs の徹底で塞ぎました。台帳に「時点」の概念を最初から入れておくと、遡及処理・再計算・監査の全部が正攻法で書けます。 それでも消費が原資を超えたら(直接インポート等でしか起き得ない)、例外ではなく 監査ログに記録して続行 します。台帳の読み取りは決算処理ではないので、1件の不整合で全体を止めない。ただし絶対に黙殺しない。 冪等キー: リトライを設計から消す 発行も焼却も、必ず呼び出し側が決めた idempotency key を持ちます。 キーは「業務上の一意性」から機械的に組み立てます: unlock:<user :<article 、 expire:<lotId 、 referral:<handle …。この規約だけで、 HTTP リトライ、ユーザーの二度押し、cron の重複発火が 全部安全 になる 呼び出し側から if (すでに処理済み?) という検査が消える(問い合わせではなく宣言になる) UNIQUE 制約が最後の砦としてスキーマに残る 「冪等にするのはリトライする側の責務」ではなく「受ける側が冪等なら、世界の全員が雑にリトライしてよい」— 分散系の定石ですが、SQLite の小さな台帳でも同じだけ効きます。 失効と没収も「ただのイベント」 失効スイープ は、期限切れロットの残量を EXPIRE イベントとして確定させるだけです。冪等キーは expire:<lotId なのでロットごとに1回しか起きず、5分毎の cron に何も考えず載せられます。1つだけ非自明な点: EXPIRE の ts は実行時刻ではなく expires at 時点 で記録します。こうすると「7月1日時点の残高」を後から聞かれても、スイープの遅延と無関係に正しい答えが返ります。 <strong 没収(slashing)</strong も特別な状態遷移ではありません。不正が確定したアカウントの未失効残高を、 kind = 'SLASH' の負イベントとして全額 burn するだけ。 台帳側に「凍結中」「没収済み」のようなフラグは増えません。アカウントの凍結はユーザーテーブルの関心事、ポイントの没収は台帳のイベント — 責務が混ざらないので、後から「没収の取り消し」が必要になっても ADJUST の追記で表現できます。 毎回全畳み込みで本当にいいのか 残高を読むたびにイベントを全部畳むのは O(イベント数) です。答え: この規模では圧倒的に正しい 。1アカウントのイベントは高々数百件、SQLite はインデックス済みの数百行なら余裕で捌きます。そして追記型台帳の良いところは、遅くなってから スナップショット(ある時点までの畳み込み結果)+ 差分 への移行が機械的にできることです。導出値はいつでも再構築できる — 最初から非正規化した balance カラムで整合性バグと戦うのと、どちらが安いかは明らかだと思います。 この台帳は、第1回で書いた Durable Object の同期トランザクションの上で動いています(「残高検査 → burn → 記録」が交錯しないのは同期 API のおかげ)。シリーズの他の記事もあわせてどうぞ。