概要

deploy なしに UI 動線や通知文面を切り替えられる機能フラグ基盤を自前で構築した話です。PostHog などの外部 A/B テストサービスを「フラグ評価エンジン」として使うのではなく、Worker(エッジ)側の決定的ハッシュで variant を割り当て、PostHog はあくまで「露出イベントの記録」だけに使う設計を選びました。

このアーキテクチャの要点は「variant 割当の真のソースは自前サーバ、外部 SaaS は analytics のみ」という役割分担の明確化にあります。これにより、外部サービスの障害時もフラグ評価が止まらず、初期描画から正しい variant を出せる(SSR 的な埋め込みが可能)、E2E テストでモックが不要になる、という副産物が得られます。


学び・気づき

  • FNV-1a ハッシュによる sticky assignmentuserId:flagKey:rollout をシードにした 32bit FNV-1a を % 10000 / 100 でパーセンテージバケットに変換します。ユーザーが同じ variant を受け続ける保証をデータベースや KV を追加せず実現できます。実装が純粋関数(副作用なし)なのでテストが簡単です。

  • SUM(revision) をグローバル世代カウンタとして使う:bootstrap レスポンスをエッジキャッシュするとき、キャッシュキーにフラグのリビジョンを含めます。MAX(revision) では「既存キーより小さい revision の新キーを追加した時」に値が変わらずキャッシュが古いままになる問題がありました。SUM(revision) に変えることで任意の追加・更新が必ずカウンタを単調増加させ、キャッシュ bust が確実に走ります。

  • definition.enabledassignment.enabled は別物:テーブルの enabled は管理者が master switch を入れたかどうか(DB 上の定義)です。評価後の assignment.enabled は「そのユーザーが control 以外の variant を踏んだか」(評価結果)です。名前が同じなのでコードレビューで指摘されやすいポイントです。UI 制御は assignment.enabled ではなく variant === '<variant_key>' で書くほうが意図が明確です。

  • 露出イベントのデュアルソース設計:クライアントは PostHog の標準イベント $feature_flag_called(experiment 分析の母数になる)を送り、サーバは自前 DB(analytics_events)に feature_flag_exposed を 1 リクエスト 1 件書きます。クライアントが feature_flag_exposed を送ると母数が膨らむのを防ぐためサーバ側に集約しています。

  • hasFeatureFlagsTable() ガードによる漸進的マイグレーション:フラグ評価を呼ぶすべての経路で sqlite_master を確認し、テーブルが存在しない環境では空リストを返します。マイグレーション前後でコードを分岐しなくてよいため、本番への段階 apply がやりやすいです。

  • Admin API のペイロードサイズ上限:フラグの payload は bootstrap で全ユーザーに配信されます。4KB を超えるペイロードを許容すると帯域・レスポンスサイズへの影響が大きくなるため、API 受付時点で 413 を返します。

  • D1(SQLite)と Supabase(PostgreSQL)のスキーマ非対称を許容するrevision 列の型が D1 では INTEGER、Supabase では bigint です。SUM(revision) の返り値型に差が出ますが、フラグ更新回数が 2^31 に達することは事実上ないため許容しています。同様に properties_json も両者 TEXT 型で保持し、jsonb 化は別タスクに先送りにしています。


コードのハイライト

1. FNV-1a ハッシュによるパーセンテージバケット

// shared/lib/feature-flags.ts
function percentageBucket(seed: string): number {
  let hash = 2166136261
  for (let index = 0; index < seed.length; index += 1) {
    hash ^= seed.charCodeAt(index)
    hash = Math.imul(hash, 16777619)
  }
  return ((hash >>> 0) % 10000) / 100
}

Math.imul で 32bit 乗算のオーバーフロー挙動を再現し、>>> 0 で符号なし整数に変換してから % 10000 を取ることで 0.00〜99.99 の均等分布を得ます。シードに userId:flagKey:rolloutuserId:flagKey:variant を使い分けることでロールアウト抽選と variant 選択の独立性を確保しています。

2. SUM(revision) によるグローバル世代カウンタ

// worker/services/feature-flags.ts
export async function getFeatureFlagRevision(db: AppDatabase): Promise<number> {
  if (!(await hasFeatureFlagsTable(db))) return 0

  const row = await firstRow<{ revision: number }>(
    db,
    'SELECT COALESCE(SUM(revision), 0) AS revision FROM feature_flags',
  )
  return Number(row?.revision ?? 0)
}

MAXSUM に変えるだけでグローバル世代カウンタとして機能します。bootstrap キャッシュキーに f{featureFlagRevision} セグメントを追加し、フラグが変わると自動的に別のキーを参照するようにしています。

3. フラグ更新時の ON CONFLICT による atomic revision 加算

-- worker/services/feature-flags.ts (upsert SQL)
INSERT INTO feature_flags (key, ... revision, ...)
VALUES (?, ... ?, ...)
ON CONFLICT (key) DO UPDATE SET
  ...
  revision = feature_flags.revision + 1,
  updated_at = excluded.updated_at

アプリ側で previousRevision + 1 を計算してバインドしていますが、ON CONFLICT のときは DB 側で feature_flags.revision + 1 を使います。これにより同時更新が来てもバインド値(読み取り時の値)ではなく行の現在値に +1 するため、楽観的に競合に強いです。


次にやりたいこと

  • 露出イベントの重複排除:現状は 60 秒の edge cache TTL 内に N 回 bootstrap が来ると feature_flag_exposed が N 件書かれます。${userId}:${revision} をキーに KV / D1 で dedup するか、last_exposed_revision を users テーブルに置いて変化時のみ記録するアプローチが候補です。
  • Admin UI:現状は curl でフラグを操作しています。ブラウザから PATCH できる管理画面があると非エンジニアでも操作できます。
  • クライアント側の広告ブロック対策:PostHog の $feature_flag_called は広告ブロッカーに遮断されることがあります。Worker が PostHog の /capture/ エンドポイントにリレーするサーバサイドキャプチャで対応予定です。
  • 未ログインユーザーへの匿名 assignment:現状 userId 必須のため、ログイン前の画面では常に control に倒れます。匿名 ID の採番 + ローカルストレージへの永続化で対応できますが、ログイン後の identity merge が必要になります。