内容ハッシュで重複を省き、画面を消しても走り続けるスマホのバックアップ
Androidクライアントとバックエンドが二人三脚で19GBの写真と8K動画を預かるまで。そして、緑のテストの陰に隠れていた三つのバグ。
- ファイル
- 2,329
- ある一人のカメラロール
- バックアップ済み
- 18.98 GB
- うち動画が64本
- ハッシュの速度
- 731.8 MB/s
- ゲートは毎秒50MB
- バックエンドのテスト
- 929
- クライアントにさらに203
背景
MemberHubは会員組織に19のツールを提供するDjangoのモノリスで、その一つがS3互換ストレージの上に載ったファイル管理です。ここで書くのは、同じバケットと同じモデルの上に建てた別のもの — 個人のカメラロールをその人だけの空間へ写し取るAndroidクライアントと、それを受け取るエンドポイントです。
「その人だけの」が設計の全部でした。バックアップされた写真も組織の外部キーを持ちます。ストレージの費用を誰に紐づけるかがそれで決まるからです。しかし組織全体を対象にするどの一覧も、個人の所有者が入っている行を除外します。したがって組織の管理者ですら、自分自身のバックアップをファイル管理の画面からは見られません。ブラウザ用のビューアが、フィルタを緩めるのではなく会員ポータルに新しい扉を開ける形になったのは、そのためです。
もう一つの制約は、私の手元に端末がないことでした。だから方針はこうなりました — 自分で測れるものをゲートに選ぶ。データベースか、アクセスログか。そこに映らないものは、確かめたことにせず「未着手」と書き残す。相手にするカメラロールはおよそ2,300点、約36GB。8K動画の一本は20.7秒で202MBあります。
作ったもの
クライアントは一度に一件だけを扱う状態機械です。カメラのフォルダを走査し、ファイルをハッシュし、そのハッシュをサーバーがすでに持っているか尋ね、持っていなければ署名付きのPUT先をもらい、バイト列を上げ、確定する。どこまで進んだかは端末側の台帳が一行ずつ記録します。走査するのは端末全体ではなくカメラのフォルダだけです。最初の版はチャットの画像やスクリーンショットまで抱え込んでしまいましたし、Androidのアルバムとは要するにフォルダのことだからです。
- 走査カメラのフォルダを、台帳が決める窓で
- ハッシュ端末上でストリーミングSHA-256、読んだバイト数で数える
- 照会そのハッシュをサーバーがすでに持っているか
- 署名新しいオブジェクトキー一つ分のPUT先
- PUTバイト列はバケットへ直接
- 確定サーバーがキーをHeadObjectしてからready
ハッシュはファイルをストリーミングしながらSHA-256を計算する小さなKotlinのモジュールです。重複判定の鍵がこれに乗るので、最初に作りました。5674ミリ秒で4152.0MB、毎秒731.8MB。設計時のゲートは毎秒50MBで、実機のsha256sumとダイジェストも一致しました。返すのは統計上のファイルサイズではなく、実際に読んだバイト数です。途中で終わったストリームは、前半だけの整った形のSHA-256と、水増しされた速度の数字を返します。つまり、失敗のほうが成功よりも良い結果に見えてしまいます。しかも前半だけのハッシュは衝突します。同じカメラで撮った二本の動画はヘッダーを共有するので、二本目は永久にバックアップされません。
サーバーはクライアントの言うことを何一つ信じません。確定の処理はキーをHeadObjectしてから本当のサイズと種別を記録するので、readyという状態は「送ったはずだ」という主張ではなく、バイト列がそこにある証拠になります。受け付けるのはpendingのままの行だけなので、確定を再送してもクォータは書き換わりません。
バックグラウンド実行は、Androidにプロセスを回収させないためだけのフォアグラウンドサービスです。その前提のほうを先に確かめました — React NativeのJSスレッドは、プロセスが生きているかぎりAndroidでは動き続けます。iOSのように止まりません。そして端末からの報告ではなくサーバーから測りました。アプリを閉じて画面をロックした状態で、4分間に63ファイル・479MBが届いています。
使えるものにしているのは両端の二つです。会員ポータルのPhotosタブは月ごとのグリッドとライトボックス、ダウンロード、ゴミ箱を持ちます。もう一つはトランスコードのワーカーです。バックアップの経路はWeb経由のアップロードが通る派生物のパイプラインを通らないので、8K HEVCのクリップはきれいに上がったまま、どのブラウザでも再生できませんでした。タイマーが5分に一本ずつ、最低の優先度で拾い、1080pのコピーとポスターフレームを書きます。
設計上の判断
ハッシュは、それを前提に設計する前に測る。 内容ハッシュは重複判定の鍵として明らかに優れていますが、それはカメラロール全体を電話の上で流せる場合に限ります。もう一方の案はサイズと撮影日時とファイル名の組で、安いかわりに、まさに肝心な場面で間違えます。先に測ったので、答えは一本のスパイクで済み、バックエンドの作り直しにはなりませんでした。
Wi-Fi限定は既定でオン。そして「確実にWi-Fi」でないものはすべてWi-Fiではない扱い。 失敗の方向は対称ではありません。Wi-Fi寄りに間違えれば誰かのモバイル通信で36GBを使い、逆に間違えても遅れるのは次の起動までです。
ネイティブとネットワークの依存はすべて注入する。 クライアントのコアはプラットフォームのモジュールをimportすることもfetchを呼ぶこともできず、それをgrepが強制します。だから端末が手元になくても200のテストが書けました。同時にそれがテストの限界でもあります。この環境はコンポーネントを一切レンダリングしないので、あるビルドは暗い背景に黒い文字を載せたまま、緑のテストとともに出荷されました。
うまくいかなかったこと
バックアップが自分自身を締め出し、画面には何の異常も出なかった。 実行の終わりに、401や403で止められた場合を除いて、現在時刻がウォーターマークとして刻まれます。そして走査は端末に「その時刻より後に作られた資産」を尋ねます。ウォーターマークがいったん現在に追いつくと、すでに端末にある写真は二度と列挙されません。走査は、項目が実行に入る唯一の入口です。「止められていない」は「きれいに終わった」ではありません。 接続が切れたときのステータスは0で、それは再試行扱いになり、どのカウンタも増やさず台帳の行にも触れません。だから4,118件を未処理のまま流し切った実行が、完璧に見えます。

診断してくれたのは0 already backed upという一行でした。写真は現に預かってあるのだから、それを返す走査であれば「すでにある」として数えたはずです。ゼロということは、資産の一覧が不作だったのではなく、空だったということです。全部ゼロだと読むのではなく、どのカウンタがゼロなのかを読む。 その下にはもっと悪い同型の欠陥がありました。台帳は「再開地点」だと書かれていたのに、誰もそれを読み返していなかったのです。台帳の再開分岐に入れるのは走査が返した資産だけで、その走査こそが返すのをやめていました。到達できない再開経路は、動いている再開経路とまったく同じ顔をします。 いまは台帳が走査の窓を決めています。
二つの呼び出し元のうち片方にしか配線されていなかったロック。 バックグラウンドの起床が前面の実行と衝突しないようにロックを置いたのに、取得していたのは定期実行の経路だけでした。それを証明したはずのテストが緑だったのは、テストが自分でロックを先に取っていたからです。本番のコードが作るはずの状態をテスト自身が用意しているなら、それはそのコードのテストではありません。 珍しい競合でもありませんでした。起床が発火するのはアクティビティが背面にいるときだけ、つまりこの機能が存在する理由そのものである画面オフ中の転送のときです。しかもロックを片方にだけ配線させたのは、私自身が書いた計画の文章でした。
8Kの1フレームのデコードに24.7秒かかった。 ほかのサブプロセス補助と共有する20秒の上限に当たり、ポスターフレームは作られるそばからタイムアウトしていました。いまはフレーム抽出だけに90秒の予算があり、ワーカーは自分がたった今書いた1080pのコピーから読みます。ついでに分かったのは、そのサムネイルがそもそも一度も存在していなかったことです。アップロードは全ファイルにサムネイル用のキーを先取りしていましたが、クライアントは動画のサムネイルを意図的に作りません。60ほどの行が、誰も上げていないオブジェクトの名前を持っていたわけです。先取りしたキーは主張にすぎません。 規則は「キーが保存されているならオブジェクトは存在する」に変わり、古いキーは配備の前に消す必要がありました。さもなければ空のタイルが壊れたタイルに変わっていました。
結果
バックアップにはある一人のカメラロールから2,329ファイル・18.98GBが入っています。64本の動画のうち60本はトランスコード済みでポスターフレームを持ち、残る4本はバイト列が上がりきらなかった行です。
バックエンドは929、クライアントは22スイートに203のテストがあり、新しいガードはすべてミューテーションで確かめました。その数字が覆っていないものは、ほのめかさずに書いてあります — この構成のテストがどうやっても届かない実機確認が11項目、そして正しく並んで鍵も振られているのに、ロケールが設定されないので決して表示されない日本語の文字列。