Cloud Datastore用キャッシュパッケージの検討

現在App Engine(Standard Environment) + Go1.11環境*1goonというパッケージを利用し、Datastoreのデータをキャッシュしています。
goonはバックエンドとしてApp Engineのmemcache*2を使用しており、こちらがGo1.12から使えなくなってしまうので他のパッケージに移行しなければなりません。

App Engineのmemcacheに依存しない既存パッケージとしては、

などがありますが、どちらも独自のクライアントを作成するものとなっています。

goonを使っていて気になったのが、独自クライアント方式はGoogleが提供するパッケージには無い便利な機能があるというメリットがあるものの、本家の機能が全て使えなかったり、そもそもパッケージとしての使い方が異なっているため、Go1.11以降で推奨されているcloud.google.com/go/datastoreへの移行はもちろん、元になっているgoogle.golang.org/appengine/datastoreに戻すことすら手間がかかってしまうという点です。

機能面で困ることなく、ずっと使い続けることができるのであれば特に気にするところではないのかもしれませんが、goonの場合は更新があまり活発ではなく、移行時に大幅にコードを書き直さないといけないことがわかっているため、次のようなパッケージを検討することにしました。

  • cloud.google.com/go/datastoreClientをそのまま使うことができる
  • 使い方が大きく変わるような付加機能は提供しない
  • キャッシュのバックエンドを自由に選択できる

上記を実現するのに使用するのがgRPCのUnaryClientInterceptorというAPIです。
UnaryClientInterceptorはリモート呼び出しの代わりに呼ばれる関数となっています。 DatastoreはgRPCで各種オペレーションを実行するため、NewClientoptsUnaryClientInterceptorを渡すことでキャッシュ処理を差し込むことが可能です。

使用例:

// optを渡さなければキャッシュを無効にできる
opt := option.WithGRPCDialOption(
    grpc.WithUnaryInterceptor(MemoryInterceptor),
    // grpc.WithUnaryInterceptor(MemcacheInterceptor),
    // grpc.WithUnaryInterceptor(RedisInterceptor),
)

ただし、UnaryClientInterceptorに渡されるmethodreqreplyといった引数はDatastoreのClientの各メソッドと1:1で対応しているわけではないため、どのメソッドがどのgRPC呼び出しになるのかを把握した上でキャッシュ戦略に応じた実装をしていく必要があります。

Client gRPC
Get /google.datastore.v1.Datastore/Lookup
GetMulti /google.datastore.v1.Datastore/Lookup
Put /google.datastore.v1.Datastore/Commit
PutMulti /google.datastore.v1.Datastore/Commit
Delete /google.datastore.v1.Datastore/Commit
DeleteMulti /google.datastore.v1.Datastore/Commit
Mutate /google.datastore.v1.Datastore/Commit
Run /google.datastore.v1.Datastore/RunQuery
Count /google.datastore.v1.Datastore/RunQuery
GetAll /google.datastore.v1.Datastore/RunQuery
RunInTransaction NewTransaction後、CommitまたはRollback
NewTransaction /google.datastore.v1.Datastore/BeginTransaction
Transaction.Get /google.datastore.v1.Datastore/Lookup
Transaction.GetMulti /google.datastore.v1.Datastore/Lookup
Transaction.Put Transaction.Commitにまとめられる
Transaction.PutMulti Transaction.Commitにまとめられる
Transaction.Delete Transaction.Commitにまとめられる
Transaction.DeleteMulti Transaction.Commitにまとめられる
Transaction.Mutate Transaction.Commitにまとめられる
Transaction.Commit /google.datastore.v1.Datastore/Commit
Transaction.Rollback /google.datastore.v1.Datastore/Rollback

goonに合わせたキャッシュ戦略とする場合、

  • トランザクションでキー指定のデータ取得時にキャッシュの参照や保存を行い、
    • Get
    • GetMulti
  • データ変更時にキャッシュの削除を行う
    • Put/Transaction.Put
    • PutMulti/Transaction.PutMulti
    • Delete/Transaction.Delete
    • DeleteMulti/Transaction.DeleteMulti
    • Mutation/Transaction.Mutation

ので、LookupCommitの2つのRPCをインターセプトすることになります。

func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    switch method {
    case "/google.datastore.v1.Datastore/Lookup":
        // キャッシュの参照や保存
        return nil
    case "/google.datastore.v1.Datastore/Commit":
        // キャッシュの削除
        return nil
    }
    return invoker(ctx, method, req, reply, cc, opts...)
} 
/google.datastore.v1.Datastore/Lookup (キャッシュの参照や保存)

LookupreqとしてLookupRequestを受け取り、結果はreplyLookupResponseを設定します。

実装する処理としては次のような流れです。

1. reqのread_optionsをチェックし、トランザクション内だった場合はinvokerを呼び出してreturnする
2. reqのkeysを使ってキャッシュを参照する
   *  全てのデータが取得できればreplyのfoundに設定してreturnする
3. reqのkeysを2で取得できなかったキーのみにしてinvokerを呼び出す
4. replyのfoundに2で取得したデータを追加する
5. 3で取得したデータをキャッシュに書き込む

データが見つからなかった場合のハンドリングはClientGetGetMulti内で行われるため、UnaryClientInterceptorの中で特殊な処理をする必要はありません。

/google.datastore.v1.Datastore/Commit (キャッシュの削除)

CommitreqとしてCommitRequestを受け取り、結果はreplyCommitResponseを設定します。

実装する処理としては次のような流れです。

1. invokerを呼び出す
   * エラーだった場合はreturnする
2. reqのmutationsにupdate、upsert、deleteがあれば対応するキーのキャッシュデータを削除する
   * 削除に失敗した場合はエラーを返す

キャッシュの削除に失敗すると古いデータが残り続けてしまうため、

のいずれかを行わないとデータに不整合が生じてしまいます。

これについて、特に困っているのがRunInTransactionの場合、キャッシュ削除の失敗はロールバックの対象にならず、個別にロールバックを呼び出すこともできないというところです。
そのため、

  • キャッシュのバックエンドにリトライ処理を入れる
    • 無限にリトライしてしまう可能性もある...
  • キャッシュに有効期限を持たせる
    • 一定時間は不整合を許容できるのであれば...
  • キャッシュを削除してからDatastoreを更新する
    • キャッシュ削除〜Datastore更新の間に別の処理が入ると結局不整合になる
    • それならキャッシュ戦略を変更した方が...
  • いっそのことRunInTransactionを使わないようにする

などなど、このあたりはもう少し検討をしなければいけない状況です。
ただ実際に動かしてみないとわからないところもあると思うので、近いうちに実装して色々と試してみようと思っています。