About

DBなしでGitHubリポジトリを保存先に — 成立条件は「1人」ではなく「append-onlyのイベントログ」

マイグレーション0本のブックマークアプリ(app421)が複数端末で回る理由

RailsアプリでDBの代わりにGitHubリポジトリを保存先にしていいかを判断するための記事だ。読み終わると、どんなデータなら大丈夫でどんなデータならだめか、複数端末から同時に書いても壊れないための最低限の仕組みがわかる。

このアプリにはマイグレーションがない

app421(YT Mark)はYouTubeのブックマークアプリだ。保存して、分類して、その場に合わせて取り出してプレイリストにする。どう見てもCRUDアプリなのに、テーブルが1つもない。

データはユーザー本人のGitHubリポジトリに、JSONL(1行にJSONが1つ)のファイル1つで積まれる。ブックマークを保存すると {"op":"add",...} が1行、削除すると {"op":"del",...} が1行増える。書くのはChrome拡張、読むのはRails。サーバーは状態を一切持たない。

「gitをDBとして使う」試みはたいてい失敗する。でもこれは回っている。なぜかがこの記事の中身だ。

Extensions write. Rails only reads and folds.Extension on PCpending queueExtension on laptoppending queueeach flush: GET file, append lines, PUT whole file with shaGitHub repo{op:add, id:1, at:...}{op:played, id:1, ...}{op:del, id:1, at:...}GET, 60s cacheRails (read only)sort_by atfold into stateno tablesWhen two devices write at the same timePUT with old sha409 Conflictre-read, append, retryBoth sides only add lines, so merge = remote lines + my lines. Nothing to resolve.

書き込み: 丸ごとPUT、衝突したらやり直し

GitHub Contents APIには部分更新がない。ファイルを丸ごと上書きする。その代わり、PUTのときに手元の sha を一緒に送ると楽観的ロック(先に誰かが変えていたら拒否)がかかる。別の端末が先に書いていれば409が返る。

拡張側は3段構えで処理する。同じブラウザ内ではPromiseチェーンで書き込みを一列に並べる。毎回リモートのファイルを読み、自分のイベントを後ろに足して丸ごとPUTする。409なら読み直して足し直す。オフラインなら pending 配列に溜めて後で送る。

バージョンカラムとトランザクションで自作したらそれなりに手間だった楽観的ロックが、APIの仕様に最初から入っていた。

読み込み: Railsは畳むだけ

Railsはファイルを取ってきて(60秒キャッシュ)、イベントを現在の状態に畳む。要はこの1行だ。イベントを at の時刻順に並べてから畳む。だから複数端末から押し込まれた行がファイル内で混ざっていても、結果は同じところに落ち着く。

削除も行を消さず、del イベントを1行足す。書き手を拡張1つにし、Railsを読み取り専用に縛ったおかげで、競合処理のコードが1か所にしかない。

成立条件は「1人」ではない

よく「個人用だからgitでもいい」とまとめられる。そう覚えると次のアプリで壊れる。本当の条件は3つだ。

append-onlyのイベントログであること。 409が出ても「リモートの行 + 自分の行」で合わせ直せば終わる。両方とも残る。gitリポジトリがDBの代わりにならないのはmergeのところだが、イベントログはその問題をそもそも作らない。逆に一人で使っていても、行をその場で書き換える構造だとgitに載せた瞬間につらくなる。

行単位のテキストであること。 JSONLなので追加は1行、衝突も行単位で起きる。人がリポジトリで開いて読むこともできる。SQLiteファイルをコミットする変種はだめだ。バイナリなのでdiffに意味がなく、リポジトリが膨らむだけになる。

丸ごと読んでいいサイズであること。 クエリがない。絞り込みも集計も全部メモリ上でやる。数千件まではむしろこちらの方が楽だ。インデックスの悩みもN+1もない。

実際に踏んだもの: 0と「なし」

リンクを右クリックで保存するとoEmbed(URL1つからタイトルやサムネイルなどを受け取る仕組み)で情報を取るが、ここには動画の長さがない。そのため d: 0 のaddイベントが入ってくる。最初はそれをそのまま受け取り、既に知っていた長さを0で上書きした。その動画は「10分以下」フィルタから静かに消えた。

今は0を「取れなかった」と見なして前の値を残す。イベントを合わせる構造なら、「なし」と「0」を区別しないとデータが音もなく壊れる。

どこで壊れるか

今の問題ではなく「大きくなったら」の話だ。書くたびにファイル全体をPUTするので、1万件を超えるとブックマーク1つのために数MBを上げることになる。そのときは月別ファイルに分ければ構造を変えずに乗り切れる。同じfoldのルールがRubyとJSの2か所にあるので、ルールを変えると2か所直す必要がある。削除はtombstone(消したという印)として永久に残るので、機微なデータには使えない。残りの限界と普通のDBとの比較は下の詳細に表で置いた。

DBを置くかどうか、データをどこに置くかといった設計段階で詰まっているなら、negabaro に聞いてもらってかまわない。こういう小さな構成を自分たちで回してきた経験から一緒に考える。

どこに置くか

保存先合うデータ同時書き込み運用の手間
GitHubリポジトリ + JSONLイベント追加中心、数千件、ユーザーがデータを手元に持ちたいshaの楽観的ロック + リトライマイグレーション・バックアップなし
GitHubリポジトリ + SQLiteファイル合う場面はほぼないファイル単位で衝突、マージ不可リポジトリが膨らむ
普通のDB(SQLite・PostgreSQL)その場での更新、検索、複数人トランザクションマイグレーション・バックアップ・サーバー

その場で書き換えるデータが多い、または検索が要るなら迷わずDBだ。

大きくなると壊れるところ

箇所今超えたら
ファイルサイズ1MBを超えるとContents APIがcontentを空で返すのでdownload_urlで取る書くたびに全体PUT。月別ファイルに分ける
リトライ最大2回3回目の衝突ならpendingに残って次の番を待つ。失いはしない
rate limit認証リクエストは1時間5,000回、60秒キャッシュで守る端末が多く再読み込みが頻繁だと効いてくる
foldのルールRubyとJSに同じルール変えるなら2か所直す

fold — 時刻順に畳む

def self.fold(events)
  events.sort_by { |e| e["at"].to_s }.each_with_object({}) do |e, map|
    next_state = apply(map[e["id"]], e)
    map[e["id"]] = next_state if next_state
  end
end

# 長さ0は「取れなかった」。既に知っている値を消さない
d: e["d"].to_i.positive? ? e["d"].to_i : prev&.dig(:d).to_i

キーポイント

1

データを状態ではなくイベント(add・del・played)として1行ずつ積む

2

書き手は1つ(拡張)にし、GET → 行を足す → shaと一緒に丸ごとPUT

3

409なら読み直して足し直す。オフラインならpendingに溜める

4

Railsは読み取り専用。時刻順に並べてからfoldし、複数端末のログを1つの状態にまとめる

5

マージ時は「なし」と「0」を区別する。大きくなったら月別ファイルに分ける

ユースケース

ユーザーが自分のデータを持つ個人ツール・ブラウザ拡張 サーバーをステートレスに保ちたい小さなアプリ
← ログインなしの「本人モード」 — 秘密URL認証は1:1ではなく1:Nだ Pumaのスロット枯渇とは?CPU 21%で502が6万件出た理由 →