839の日記

業務とは関係ない趣味の話を書くブログです。

firestoreのページネーションプラクティス

以下のようなシンプルなデータセットに対してのページネーションを考える。

DocumentID likeCount
0 20
1 10
2 30
3 20
4 10
5 20
6 10

これを2件ずつlikeCountで降順ソートしたページネーションを実現したい場合は以下のような形になる。

Page DocumentID(likeCount)
1 2(30)
1 5(20)
2 3(20)
2 0(20)
3 6(10)
3 4(10)
4 1(10)

これを実現する手段で思い浮かぶのはオフセットだと思うが、firestoreではオフセットの利用が推奨されていない*1

オフセットは使用しないでください。その代わりにカーソルを使用します。オフセットを使用すると、スキップされたドキュメントがアプリケーションに返されなくなりますが、内部ではスキップされたドキュメントも引き続き取得されています。スキップされたドキュメントはクエリのレイテンシに影響し、このようなドキュメントの取得に必要な読み取りオペレーションは課金対象になります。

refs: Cloud Firestore のベスト プラクティス  |  Firebase

ここで出てくるカーソルは クエリカーソルを使用したデータのページ設定  |  Firebase で説明されている。 ページネーションを実現する上で気をつけないといけないことは

  • 同じデータが複数ページに跨って出ないようにする
  • 同じ値(e.g. likeCount)の違うデータがページを跨ぐときにスキップされないようにする

という点だと思う。 しかし上記のリファレンスからは2点を達成するための方法がやや気づきにくい。

解法の一つとして Firestore で いいね順(Score順)Sort + Paging するポイント - Qiita の記事では createdAt を使った複数のOrderByで解決する方法が記載されている。 最初に記載したデータセットcreatedAt のフィールドを追加すれば複数フィールドOrderByで大体のケースは正しいページネーションが行える。

今回は更新順の取得 => createdAt の値が下がっていく方向に進むので、開始日時を 1ms 減算

ただ複数フィールドのOrderByを行う方法はいくつかデメリットがある。

  • createdAt が1ms以内に収まるドキュメントが複数あった場合に期待通りの結果にならないケースがある(はず)
  • createdAt がないと期待したページネーションができない*2
  • 複数フィールドOrderByを行うために複合インデックスを明示的に作成する必要がある

このような問題はカーソルの指定にDocumentSnapshotを指定することで解決できる。

var lastDoc *firestore.DocumentSnapshot
for {
  client.Collection("collectionID").OrderBy("likeCount", firestore.Desc).Limit(2)
  if lastDoc != nil {
    q = q.StartAfter(lastDoc)
  }
  dss, _ := q.Documents(ctx).GetAll()
  if len(dss) == 0 {
    break
  }
  lastDoc = dss[len(dss)-1]
}

これをすると最初の表に記載した期待通りの順番でドキュメントが返ってくる。
likeCountを使うものに書き換えると以下のようになる。

var lastLikeCount int64 = -1
for {
  client.Collection("collectionID").OrderBy("likeCount", firestore.Desc).Limit(2)
  if lastLikeCount != -1 {
    q = q.StartAfter(lastLikeCount)
  }
  dss, _ := q.Documents(ctx).GetAll()
  if len(dss) == 0 {
    break
  }
  if v, ok := dss[len(dss)-1].Data()["likeCount"].(int64); ok {
    lastLikeCount = v
  }
}

これを実行すると以下のような結果が取得される。
赤字のデータは期待していたが実際には取得できなかったデータを示す。

Page DocumentID(likeCount)
1 2(30)
1 5(20)
- 3(20)
- 0(20)
2 6(10)
2 4(10)
- 1(10)

期待通りの結果からいくつかドキュメントが欠損してしまっていることがわかる。*3 ちなみにStartAfterからStartAtに変更すると無限ループが発生してしまうのでStartAfterを使っている。

likeCountをcursorに指定する場合と比べ、ドキュメントを指定する場合はgoクライアントのリクエストで以下のような差分がある。

   ...
   StartAt: &pb.Cursor{
     Values: []*pb.Value{
       {ValueType: &pb.Value_IntegerValue{IntegerValue: 20}},
+      {ValueType: &pb.Value_ReferenceValue{ReferenceValue: "projects/:projectID/databases/(default)/documents/collectionID/5"}},
     },
   },
   ...

上記の通り、ReferenceValueによるソートが入っている。 これは以下の点でメリットがある。

  • このようなケースに関しては複合インデックスが不要
  • 不要なフィールドを指定しなくて良い
  • 1ms以内にドキュメントが複数作成されていても期待通りの結果が返る

ReferenceValueは恐らくfirestoreの中で特別な立ち位置にいるため複合インデックス無しでこのような事ができるのだと思う。
少なくともReferenceValueはソートされていると思われるレスポンスが返ってきている。*4 事実上複数フィールドに対してOrderByをしているのだが、ReferenceValueに関しては複合インデックスの制約を受けないようだ。

このあたりはもしかしたらドキュメントにちゃんと書かれてるかもしれないので見つけた人がいたらぜひ教えて下さい。

*1:RDBでも大規模なデータではオフセットだとパフォーマンスは出にくい

*2:ページネーションのために本来不要なフィールドが必要、という意

*3:クライアント側で工夫すればlikeCountをcursorに指定する方式でもできるとは思うが、複雑度は高い

*4:検証例のDescの場合ではDocumentIDも降順になっているが、AscにするとDocumentIDも昇順になっている

firebase hostingのSSL証明書の更新が遅れていた

firebase hostingで静的配信しているコンテンツに対してユーザから証明書のエラーが起きたという報告がありました。 この記事は同じ問題を踏んでいる人がいるかもしれないのでメモがてら書いています。

最初に結論を述べておくとGoogleの不具合だったようです。 以下は調査したメモですがGoogleの不具合だったようなのであまり参考にはならないと思います。

背景として

  • カスタムドメインは未使用( *.web.app を使用)
  • 報告があった時刻と証明書の更新時刻が近い

といったことを確認しています。

まずカスタムドメインは利用していないので証明書に関してもよしなにfirebase hostingが更新してくれると思っています。

Firebase automatically provisions SSL certificates for all your domains so that all your content is served securely.

refs: Firebase Hosting  |  Firebase

報告された時刻は2/16の11:17なのですが、使用して動かないなと思い調べた上で証明書の問題という報告をしてくれたと推測できるので、 実際に現象を確認したのはもう少し前だと思われます。 現在確認した証明書の情報としては以下のようなものでした。

  • タイムスタンプ: 2020/02/16 11:12:46 JST
  • 有効になる日付: 2020/02/16 10:12:46 JST
  • 無効になる日付: 2020/05/16 10:12:46 JST

証明書の更新周期に関してあまり詳しくないのですが、Let's Encryptと同じ挙動をしているとするなら

  1. 証明書を更新した時刻がタイムスタンプ
  2. 有効になる日付は更新した時刻の1時間前から有効
  3. 証明書を配信した段階からvalidである

といった形だと思います。

そのことから実際に証明書が更新されたタイミングは11:12に更新されたものと思われます。 そして仮にGoogleが3ヶ月周期でbatchを回しているとなると10:12-11:12の間は証明書がinvalidだったのではないかと推測しています。 ただ、これに関しては既に終わってしまったことなので検知しようがないため、やれることといえば次回更新と思われる5/16に同様の観測をするぐらいしかないですね...。

とここまで書いた後に同様の症状を観測している人がいないかを調べたところ、他にも観測していた人がいるようです。

どうやら02/16 6:50JST-02/16 11:45JST頃まで何かバグっていたのが原因だったようです。
恐らく次回の更新では大丈夫でしょう。

(02/16 16:30時点ではhttps://status.firebase.google.com/summary にインシデントはまだ載っていない模様...)

じぶん ReleaseNote v1.0

2020年、1月版です。
年を跨いだのでメジャーバージョンが上がりました。
来年も引き続き更新できればメジャーバージョンが上がります。

Tech寄り

1ヶ月経ったのでGKEクラスタのサービスSLA確認したらほぼ100%でした(99.99%)。
preemptive VM使ってもいい感じに安定してきた。

椅子

自宅用の椅子を買いました。
高いやつじゃなくてニトリで売ってる4万弱ぐらいのやつ。
買い替えた理由は今使っている椅子がもう寿命で本格的にやばかったからです。

椅子といえばアーロンチェア感はありますが、自分の中では4万弱の椅子で正直十分体感が良かった。
というかそのくらいからもはやアーロンチェアとの違いがわからなくなってくるのでとりあえず自宅は安めのやつを購入しました。
会社の椅子も実はずっと変えたいと思っているので今回買った椅子がよかったら同じの買おうかな。

2月中旬ぐらいに届く予定なので楽しみ。

ポケモン剣盾

久々にスイッチやりまくってはまってます。
こんなにコンシューマゲームやったのは久しぶりで既にSplatoon2のプレイ時間を超えている…。
今月進捗が悪かった主な原因はポケモンです。

ルビサファ以来のカムバック勢で今作レート戦初デビュー。
色々と考えることが多くて難しいと思いつつ1月のレート戦で無事マスボまでは上がれました。
今月もレート戦頑張っていきたい、目指せ4桁。

f:id:husq:20200203214235j:plain
114424位

リリースノートが遅れたのもポケモンのせいです。