UnlockOS Developers
← 記事一覧に戻る
⚡

フェイルクローズドな状態管理:1動詞・1書き手・1配信

2026年9月28日→2026年10月4日
7 分
239 commits
深度 8/10
state-machinesecuritytypescriptreliabilityidempotency

フェイルクローズドな状態管理:1動詞・1書き手・1配信

はじめに

スマートロックのプラットフォームは、UI の下を覗けば state machine の山です。宿泊は予約済みか入館済みか、キーは発行済みか失効済みか、メンバーシップは申請中・有効・支払い遅延・終了予定・終了済みのいずれか。そしてそれらの状態はすべて、自分たちのデータベースの外側 — 決済プロバイダ、ロックコントローラ、メッセージングチャネル — にも同時に写し取られています。

本当に痛手になる失敗モードはクラッシュではありません。乖離(divergence) です。自分たちのデータベースは「メンバーシップは月末で終了」と言っているのに、課金プロバイダはそんな話を聞いたこともない。あるいはゲストのブラウザは「決済完了」と表示しているのに、台帳はそう考えていない。あるいは、冪等でないハンドラにリトライが再突入したせいで、同じ承認メールが3回送られる。

本記事では、メンバーシップのライフサイクル、決済レーン、通知パイプラインを統合し、物理的な扉の前に出しても良いと思える状態に仕上げる過程で収束したパターンを紹介します。

1. 遷移ごとに動詞は1つ

最初に見つかった問題はバグではなく アリティ(arity) でした。メンバーシップは、ユーザー自身のキャンセル画面からも、管理者の強制キャンセルからも、申請の却下からも、プランの廃止からも終了できました。4つの画面、4つのコードパス、「終了済み」の微妙に異なる4つの定義。

書き手が4つあるということは、課金を止め忘れる場所が4箇所、下流の予約をキャンセルし忘れる場所が4箇所、状態テーブルがドリフトする箇所が4箇所あるということです。

修正は退屈ですが効果的です。すべてのパスを1つの動詞に畳み込み、理由をパラメータにする こと。

export type MembershipEndReason =
  | { kind: 'self_cancel'; effectiveAt: 'period_end' | 'month_boundary' }
  | { kind: 'force_cancel'; actorId: string; note: string }
  | { kind: 'application_rejected'; actorId: string }
  | { kind: 'plan_retired'; planId: string };
export async function endMembership(
  id: string,
  reason: MembershipEndReason,
): Promise<TransitionOutcome> {
  const current = await loadMembership(id);
  const next = resolveEndState(current.status, reason);
  if (!next) return { status: 'noop', code: 'MEMBERSHIP_ALREADY_ENDED' };
  await stopExternalBilling(current, reason);
  const applied = await casMembershipState(id, current.status, next);
  if (!applied) return { status: 'conflict', code: 'MEMBERSHIP_STATE_CHANGED', current: current.status };
  await cancelFutureMemberReservations(id, next.endsAt);
  return { status: 'applied', to: next.status };
}

関数の形そのものより重要な点が2つあります。

  • stopExternalBilling はローカルの書き込みの 前 に実行されます。外部世界が拒否した場合、自分たちのデータベースではまだ嘘をついていません。
  • 書き込みは無条件の update ではなく compare-and-set です。
update memberships
   set status = $3,
       ends_at = $4,
       updated_at = now()
 where id = $1
   and status = $2
returning id;

返ってくる行が0件なら、読み取りと書き込みの間で誰かがその行を動かしたということです。それは成功ではなくコンフリクトです。

状態テーブルを書き起こし、そのうえでガードする

許可される遷移を、文字どおりの表(from-state × event → to-state)として ADR に書き出し、さらに 誰が メンバーシップの状態を書いてよいのかにガードを追加しました。アプリケーションコードは動詞を呼ぶだけで、それ以外は status カラムに直接触れません。データベースレベルのルールによって、これは「努力目標」ではなく強制可能なものになります。

create or replace function guard_membership_status()
returns trigger language plpgsql as $$
begin
  if new.status is distinct from old.status
     and current_setting('app.membership_verb', true) is null then
    raise exception 'membership status must change through a membership verb';
  end if;
  return new;
end $$;

重要なのは具体的な仕組みではありません。重要なのは、「このカラムを書き込むのはこのモジュールだけ」がコードレビュー上の慣習ではなく、実行時に検査可能であるべき だという点です。

2. 信頼できる情報源に到達できないときはフェイルクローズドに

今回の一連の修正で最も鋭い教訓です。決済プロバイダが利用不能な最中にメンバーがキャンセルを要求し、旧コードは何食わぬ顔でローカルに終了日をスケジュールしました。サブスクリプションは課金を続けます。データベースは「終了予定」、プロバイダは「有効」。次の請求が来るまで誰も気づきませんでした。

遷移に外部レッグがある場合、依存先に到達できないなら、ローカルだけの成功ではなく 拒否(refusal) を返さなければなりません。

export async function requestSelfCancel(membershipId: string): Promise<TransitionOutcome> {
  const membership = await loadMembership(membershipId);
  if (membership.billing === 'stripe') {
    const schedule = await billing.scheduleCancelAtPeriodEnd(membership.subscriptionId)
      .catch(() => null);
    if (!schedule) {
      return { status: 'refused', code: 'BILLING_UNAVAILABLE' };
    }
    return endMembership(membershipId, { kind: 'self_cancel', effectiveAt: 'period_end' });
  }
  return endMembership(membershipId, { kind: 'self_cancel', effectiveAt: 'month_boundary' });
}

同じ原則は管理画面のリストにも現れました。審査中の申請や、チェックアウトが進行中の申請は、終端状態へ「強制キャンセル」してはいけません — 1秒後に入金が着地するかもしれないからです。コード(APPLICATION_UNDER_REVIEW、CHECKOUT_IN_PROGRESS)付きで拒否するほうが、誰も書いていないクリーンアップジョブに期待するよりずっと良い結果になります。

経験則:遷移をエンドツーエンドで完了できないなら、その半分だけを完了させてはいけません。拒否は回復可能ですが、スプリットブレインは回復できません。

3. 英語のエラー文字列ではなく、アウトカムを返す

古くなったクリックは日常茶飯事です。管理者がメンバーリストを開き、昼食に行き、戻ってきて、すでに他の誰かが却下した申請を承認する。旧来の挙動は throw new Error('membership already processed') でした — ローカライズ不能、テスト不能、そして本物の障害と見分けがつきません。

結果をデータとしてモデル化しましょう。

export type OutcomeCode =
  | 'MEMBERSHIP_ALREADY_ENDED'
  | 'MEMBERSHIP_STATE_CHANGED'
  | 'APPLICATION_UNDER_REVIEW'
  | 'CHECKOUT_IN_PROGRESS'
  | 'BILLING_UNAVAILABLE';
export type TransitionOutcome =
  | { status: 'applied'; to: MembershipState }
  | { status: 'noop'; code: OutcomeCode }
  | { status: 'conflict'; code: OutcomeCode; current: MembershipState }
  | { status: 'refused'; code: OutcomeCode };

こうすれば UI 側が判断できます。noop なら静かにリフレッシュ、conflict ならリロードして実際に起きたことを表示、refused ならローカライズされた理由を表示しつつボタンは有効なまま。テストは部分文字列ではなくコードに対してアサートします。そして SDK の表面がこのように型付けされていれば、refused の処理を忘れた呼び出し元は本番ではなく型チェックで落ちます。

4. すべての外部境界で冪等性を

お金やロックに触れるものはすべて同じ扱いを受けます。安定したキー、記録された目的(purpose)、そして重複排除テーブル。

1つの契約を通して課金を作る。 散らばった paymentIntents.create の呼び出しは、後から誰も紐付けられない課金を生みました。ファクトリは1つ、形も1つ。

export type PaymentPurpose =
  | 'walk_in'
  | 'reservation_balance'
  | 'membership_initial'
  | 'membership_renewal';
export function buildIdempotencyKey(input: {
  purpose: PaymentPurpose;
  subjectId: string;
  attempt: number;
}): string {
  return `${input.purpose}:${input.subjectId}:${input.attempt}`;
}
export async function createIntent(input: CreateIntentInput) {
  return stripe.paymentIntents.create(
    { amount: input.amount, currency: input.currency, metadata: { purpose: input.purpose, subject_id: input.subjectId } },
    { idempotencyKey: buildIdempotencyKey(input) },
  );
}

metadata.purpose の刻印があることで、後から返金レーン、照合レポート、監査が「この課金は何のためのものか?」に、金額から推測することなく答えられるようになります。

webhook をきっかり1回だけ処理する。 プロバイダはリトライします。それが契約です。ですからプロバイダのイベント id で重複排除し、「受信済み」と「処理済み」を分けます。

create table payment_webhook_events (
  event_id    text primary key,
  event_type  text not null,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  attempts    int not null default 0,
  last_error  text
);
const claimed = await db.query(
  `insert into payment_webhook_events (event_id, event_type)
   values ($1, $2) on conflict (event_id) do nothing returning event_id`,
  [event.id, event.type],
);
if (claimed.rowCount === 0) return ok('duplicate');
try {
  await handle(event);
  await markProcessed(event.id);
} catch (err) {
  await recordFailure(event.id, err);
  if (isTransient(err)) throw err; // let the provider retry
  return ok('permanent-failure-recorded');
}

一時的な失敗と恒久的な失敗の区別に注目してください。一時的なエラーを再スローすればリトライを促せますし、恒久的なエラーを飲み込めば無限の再配信ループを止めつつ、運用者が調べられる行を残せます。

5. トランザクショナル outbox:正しい言語で、1回だけ通知する

状態変更と同じ流れでインラインにメールやチャットを送るのは罠です。送信が成功してトランザクションがロールバックすれば、起きていないことをゲストに通知したことになります。トランザクションがコミットして送信が例外を投げれば、ゲストは自分が今持っているキーについて何も聞かされません。

そこで、ゲスト向けのメッセージはすべて outbox に書き込む1つの動詞に集約し、直接送信は封鎖しました。

create table notification_outbox (
  id          uuid primary key default gen_random_uuid(),
  dedupe_key  text not null unique,
  event_type  text not null,
  recipient   jsonb not null,
  locale      text not null,
  payload     jsonb not null,
  claimed_at  timestamptz,
  sent_at     timestamptz,
  attempts    int not null default 0,
  last_error  text
);

dedupe_key(例:entry_key_issued:{stayId})こそが、発行パスが2回走ってもキーカードが1度しか配信されないことを保証します。ディスパッチャは行を claim するので、並行ワーカーが二重送信することはありません。

update notification_outbox
   set claimed_at = now(), attempts = attempts + 1
 where id in (
   select id from notification_outbox
    where sent_at is null
      and (claimed_at is null or claimed_at < now() - interval '5 minutes')
    order by id
    for update skip locked
    limit 50
 )
returning *;

そして間違えやすい部分がここです。送信が行われなかったときは claim を解放すること。await する送信の前に楽観的にフラグを立ててしまうと、リトライが永久に黙って抑制されます。

for (const row of claimed) {
  try {
    await channel.send(row);
    await db.query('update notification_outbox set sent_at = now() where id = $1', [row.id]);
  } catch (err) {
    await db.query(
      'update notification_outbox set claimed_at = null, last_error = $2 where id = $1',
      [row.id, String(err)],
    );
  }
}

ロケールは送信時ではなく enqueue 時に解決し、明示的なフォールバックチェーンを持たせることで、保存された行が自己記述的になります。

const locale = guest.preferredLocale ?? facility.defaultLocale ?? 'ja';

6. 状態を進めるのはサーバー、クライアントはそれを描画するだけ

リダイレクトベースの決済リターンは信頼できない入力です。旧フローではリターンページが決済を confirm していました — 事実上、URL が金銭の状態を進めることを許していたわけです。これを ADR として書き下ろしました。決済状態を進めるのはサーバー台帳のみ。

// Anti-pattern: the client asserts the outcome.
// await api.confirmPayment({ stayId, status: 'succeeded' });
// Pattern: the return page re-reads the server's view and renders it.
const stay = await server.getStay(stayId);
if (stay.settlement === 'pending') return renderHolding(stay); // poll or wait for webhook
if (stay.balanceDue > 0) return renderBalanceDue(stay);
return renderCompleted(stay);

同じ理屈がドアのアクセスにも当てはまります。入館キーはサーバー側で永続化され、特権的な発行パスはゲートされているため、クライアントが正しい形のリクエストを正しいエンドポイントに投げても入館権限を鋳造することはできません。入館の可否はサーバーの決定であり、クライアントはその描画を受け取るだけです。

7. 認可もまたサーバーの決定である

今回の一連の修正のいくつかは同じ形をしています。UI はすでに何かを隠していたが、サーバーはそれを拒否していなかった、というものです。

  • 会員限定とマークされたプランはピッカーでグレーアウトされていた — しかし細工されたウォークインのリクエストは依然として価格計算され、販売されてしまった。
  • 予約専用の入口にはウォークインのボタンが表示されなかった — しかしエンドポイントはウォークインを受け付けた。
  • ステータス更新の RPC が、呼び出し元の施設を確認せずに行 id を信頼していた。

隠すことはプレゼンテーションです。拒否することはアクセス制御です。これらはすべて、コードを返すサーバー側のゲートになりました。

export function evaluateCheckinRequest(input: CheckinRequest): CheckinDecision {
  if (input.config.mode === 'reservation_only' && !input.reservationId) {
    return { allowed: false, code: 'WALK_IN_NOT_ALLOWED' };
  }
  if (input.plan.membersOnly && !input.membership?.isActive) {
    return { allowed: false, code: 'MEMBERS_ONLY_PLAN' };
  }
  if (!input.config.planIds.includes(input.plan.id)) {
    return { allowed: false, code: 'PLAN_NOT_IN_CONFIG' };
  }
  if (input.blacklist.matches(input.guest)) {
    return { allowed: false, code: 'GUEST_BLOCKED' };
  }
  return { allowed: true };
}

禁止判定を共有のゲート1つに集約すると、拒否が常にコードを伴うようにもなります。その結果、監査証跡には汎用的な失敗ではなく、入館が拒否された 理由 が記録されます。

データ層では、スコープの限定は、誰かが忘れるかもしれないハンドラではなくポリシーに属します。

create policy reservations_update_own_facility on reservations
  for update
  using (facility_id = any (current_user_facility_ids()))
  with check (facility_id = any (current_user_facility_ids()));

8. 会計上の不変条件にも同じ厳密さを

クォータとお金は、別の帽子をかぶった state machine です。明示化せざるを得なかった不変条件が2つあります。

  • 対称性:月次クォータの枠は、その枠が 使われた 月に対してカウントされ、キャンセル時には同じ月に返却されます — 決して「現在」ではありません。さもないと、遅れてのキャンセルが当月のクレジットを黙って鋳造してしまいます。
  • ポリシーに縛られた返却:クォータが返却されるのはキャンセルポリシーがそう定めている場合のみで、まさに回数券と同じ振る舞いをします。そして拒否された決済は、きっかり1回だけ全額返金されます。
export function releaseQuota(usage: QuotaUsage, policy: CancellationPolicy, now: Date): QuotaDelta | null {
  if (!policy.refundsQuota(usage.startsAt, now)) return null;
  return { month: usage.usageMonth, delta: +1 };
}

デポジットについては、デポジットでカバーされない分だけを課金します。二重課金は価格計算のバグではなく、状態のバグです。

9. CI のガードレール

最後に、小さく地味な部分です。パイプライン自体にも常時有効なガードを入れました。コミットされたマージコンフリクトマーカーがあればビルドを落とし(メンバーシップ承認パスに一式出荷してしまったことがありました)、旧バージョンがローカルのデータベースに残ったまま採番し直されたマイグレーションに警告を出します。どちらも、本番では高くつき混乱を招く失敗モードに対する安価なチェックです。

まとめ

バグが物理的な扉を開けたり拒否したりするシステムを作っているなら、規律ではなく構造として強制する価値のある不変条件は次のとおりです。

  1. 遷移ごとに動詞は1つ。 呼び出し元は多数、書き手は1つ、compare-and-set は1つ。
  2. 状態テーブルを書き起こし、カラムをガードして、動詞を迂回できないようにする。
  3. フェイルクローズド。 外部レッグを完了できないなら拒否する。ローカルだけの真実を決して記録しない。
  4. 文字列ではなくアウトカムを返す。 安定したコードを伴う applied | noop | conflict | refused。
  5. お金やアクセスが絡むところには必ず冪等性キーを、加えてプロバイダのイベント id による webhook の重複排除を。
  6. すべての通知にトランザクショナル outbox を。 dedupe key、claim/release のセマンティクス、enqueue 時に解決されるロケールとともに。
  7. 状態を進めるのはサーバー、描画するのはクライアント。 リダイレクトのリターンは信頼できない入力です。
  8. 隠すことは拒否することではない。 すべての UI 上の制限には、コードとその背後のポリシーを備えたサーバー側のゲートが必要です。

どれも賢いアイデアではありません。しかしそのすべてが、午前3時に筋道立てて考えられるシステムと、謝ることしかできないシステムとの違いを生みます。

主要な発見

1
State Machine

すべてのパスを1つの動詞に畳み込み、compare-and-set で書き込む

自己キャンセル、強制キャンセル、却下、プラン廃止のすべてが異なるコードパスでメンバーシップを終了させていました。理由をパラメータとした単一の動詞に統合し、データベースレベルでガードされた compare-and-set で状態を書き込むことで、書き込みパス間のドリフトがなくなり、文書化された状態テーブルが強制可能になります。

2
信頼性

外部の信頼できる情報源に到達できないときはフェイルクローズドに

決済プロバイダがダウンしている最中にローカルでキャンセルをスケジュールすると、スプリットブレインが生じます。データベースは「終了予定」と言い、プロバイダは課金を続けます。外部レッグを持つ遷移は、半分だけ完了するのではなく、コードを伴って拒否しなければなりません。

3
エラーハンドリング

古い操作は英語の例外ではなく型付きアウトカムとしてモデル化する

安定したコードを伴う applied / noop / conflict / refused の判別可能ユニオンにより、古くなった管理操作はローカライズ可能・テスト可能になり、本物の障害と区別できるようになります。さらに呼び出し元はコンパイル時に拒否の処理を強制されます。

4
冪等性

課金には1つの契約を、webhook には1つの重複排除テーブルを

すべての PaymentIntent は purpose と決定的な冪等性キーを刻印する単一のファクトリ経由で作成され、すべての受信 webhook はプロバイダのイベント id で重複排除されます。一時的な失敗は再スローしてリトライさせ、恒久的な失敗は調査できるよう記録します。

5
アーキテクチャ

インライン送信ではなく、claim/release 付きのトランザクショナル outbox

ゲスト向け通知は dedupe key を持つ1つの outbox 動詞を経由し、キーカードはきっかり1回だけ配信されます。ディスパッチャは SKIP LOCKED で行を claim し、送信が失敗したら claim を解放することで、早すぎる送信済みフラグがリトライを永久に抑制するのを防ぎます。

6
セキュリティ

UI で隠すことはアクセス制御ではない

会員限定プラン、予約専用の入口、施設スコープのステータス更新のいずれにも、明示的な拒否コードを返すサーバー側のゲートと、書き込みを呼び出し元の施設に限定する行レベルポリシーが必要でした。グレーアウトしたボタンは決して強制点ではありません。