たごもりすメモ

コードとかその他の話とか。

ApiCallHandlerは本当にTransactionを処理できないのか?

前に書いたremote_apiのトランザクション処理は実はproduction環境そのものとは違った件の続き。remote_api_shell(およびRemoteStub)経由でTransactionを処理するとどうなるのか? を調査した結果。

結論:できませんでした。

ApiCallHandlerにTransactionを渡す

前回の試行の結果、production側に何もしない状態だとSERVICE_PB_MAPにBeginTransactionなどが定義されていないので呼び出しに失敗した。
ならばSERVICE_PB_MAPにTransactionに必要なRPCメソッド情報を追加してどうなるか試してみようじゃないか、ということで以下のようなコントローラ raw_api.py を用意。

from google.appengine.api import api_base_pb
from google.appengine.datastore import datastore_pb
from google.appengine.ext.remote_api import handler

from google.appengine.ext import webapp
from google.appengine.ext.webapp.util import run_wsgi_app

def main():
    handler.SERVICE_PB_MAP['datastore_v3']['BeginTransaction'] = (datastore_pb.BeginTransactionRequest, datastore_pb.Transaction)
    handler.SERVICE_PB_MAP['datastore_v3']['Commit'] = (datastore_pb.Transaction, datastore_pb.CommitResponse)
    handler.SERVICE_PB_MAP['datastore_v3']['Rollback'] = (datastore_pb.Transaction, api_base_pb.VoidProto)
    
    application = webapp.WSGIApplication([('.*', handler.ApiCallHandler)],
                                         debug=True)
    run_wsgi_app(application)

if __name__ == '__main__':
    main()

Transactionに必要なメソッド名 BeginTransaction/Commit/Rollback を、request/responseのPBクラス名とともに定義に加える。処理そのものはApiCallHandlerにそのまま渡してやればいい。
で、上記コントローラを適当なURIマッピングする。(あとでRemoteStubからそのURIを呼び出す。ここでは '/raw_api' とした。)

application: tagomoris-test
version: 2
runtime: python
api_version: 1

handlers:
- url: /raw_api
  script: raw_api.py
  login: admin
- url: /remote_api
  script: $PYTHON_LIB/google/appengine/ext/remote_api/handler.py
  login: admin
- url: /.*
  script: main.py

これをアップロードして、あとはもうみなさんご存じのremote_api_shell.pyから接続する。普通のput()が成功することも確認。

~/google_appengine$ ./remote_api_shell.py tagomoris-test
 (略)
The db, users, urlfetch, and memcache modules are imported.
tagomoris-test> from google.appengine.api import apiproxy_stub_map
tagomoris-test> from google.appengine.ext.remote_api import remote_api_stub
tagomoris-test> from google.appengine.tools import appengine_rpc
tagomoris-test> server = appengine_rpc.HttpRpcServer('tagomoris-test.appspot.com', lambda: ('youraccount@gmail.com', 'yourpassword'), remote_api_stub.GetUserAgent(), remote_api_stub.GetSourceName(), debug_data=False, secure=False)
tagomoris-test> stub = remote_api_stub.RemoteStub(server, '/raw_api')
tagomoris-test> del apiproxy_stub_map.apiproxy._APIProxyStubMap__stub_map['datastore_v3']
tagomoris-test> apiproxy_stub_map.apiproxy.RegisterStub('datastore_v3', stub)
tagomoris-test> class XE(db.Model): pass
... 
tagomoris-test> x1 = XE()
tagomoris-test> x1.put()
datastore_types.Key.from_path(u'XE', 27001L, _app=u'tagomoris-test')
tagomoris-test>

さて、では満を持してTransactionの実行だ。

tagomoris-test> def tx1():
...   x2 = XE()
...   x2.put()
... 
tagomoris-test> db.run_in_transaction(tx1)
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/.../google_appengine/google/appengine/api/datastore.py", line 2132, in RunInTransaction
    DEFAULT_TRANSACTION_RETRIES, function, *args, **kwargs)
  File "/home/.../google_appengine/google/appengine/api/datastore.py", line 2223, in RunInTransactionCustomRetries
    result = function(*args, **kwargs)
  File "<console>", line 3, in tx1
  File "/home/.../google_appengine/google/appengine/ext/db/__init__.py", line 805, in put
    return datastore.Put(self._entity, rpc=rpc)
  File "/home/.../google_appengine/google/appengine/api/datastore.py", line 284, in Put
    raise _ToDatastoreError(err)
BadRequestError: invalid handle: 3329134779050992549
tagomoris-test> 

残念!

実際のところどうなのか?

AppEngineのコンソールからログを見てみると、何が起きているのか、より詳しく確認できる。順序的には次の通り。

  1. BeginTransactionリクエストの処理(を行うHTTPリクエスト)が成功し
  2. Commitリクエストが失敗し
    • メッセージは "ApplicationError: 1 invalid handle: xxxxxxxxxxxxxx"
    • スタックトレースを見ると、DatastoreServiceにRPCし、その結果を受け取ってチェックした結果の例外だと分かる
  3. Rollbackリクエストが失敗している
    • エラーはCommitと同じ

スタックトレースはこんな感じ。

Exception while handling service_name: "datastore_v3"
method: "Put"
request <
  "\n\037j\032j\016tagomoris-testr\010\013\022\002XE\030\000\014\202\001\000\022\031\011-dq\025\203|\302\212\022\016tagomoris-test">
Traceback (most recent call last):
  File "/base/python_lib/versions/1/google/appengine/ext/remote_api/handler.py", line 309, in post
    response_data = self.ExecuteRequest(request)
  File "/base/python_lib/versions/1/google/appengine/ext/remote_api/handler.py", line 340, in ExecuteRequest
    response_data)
  File "/base/python_lib/versions/1/google/appengine/api/apiproxy_stub_map.py", line 78, in MakeSyncCall
    return apiproxy.MakeSyncCall(service, call, request, response)
  File "/base/python_lib/versions/1/google/appengine/api/apiproxy_stub_map.py", line 278, in MakeSyncCall
    rpc.CheckSuccess()
  File "/base/python_lib/versions/1/google/appengine/api/apiproxy_rpc.py", line 126, in CheckSuccess
    raise self.exception
ApplicationError: ApplicationError: 1 invalid handle: 9998691025158235181

なぜこういう失敗となるか。原因は以下のどれかだと推測できる。前提となる理解:BeginTransaction/Commit/Rollbackがそれぞれ別のHTTPリクエストとしてproduction環境に投げられ、処理されている。

  • 実はproduction環境に特有の情報が何か足りない
    • 可能性はある
      • が、production側で使われているPB定義(datastore_pb.py)をゴニョゴニョしたところ、PB自体の定義を行うPB(?)らしきものがあるだけで、他に追加の情報はなさそうな感じ
    • えー、いやまあ可能性はあるね?
  • BeginTransaction/Commit/Rollbackは実際には別のノードで処理されている
    • このため各ノードで「そんなトランザクションハンドルは知らん」となる
    • ただしスタックトレース上はRPC発行のあとに結果チェックでエラーになっているので、ノード上で完結する例外処理が原因ではなさそう
  • Datastore側で、どのトランザクションハンドラがどのノードから来たものかチェックしている
    • このためBeginTransactionとCommitが別々のノードから来たときに「それはおかしい」と判断できる
    • これは最低限やってそう
    • ちなみにTransaction試行を19回連続でやってみたが、すべてinvalid handleで失敗した
      • この程度の処理でそんなに多くのノードに分散しているとは思えないので、一度くらいBeginTransactionとCommitが同じノードに割り当てられたら成功するんじゃないの?
      • と思ったが全部失敗したから、まだ他にもチェック機構があるだろうな
  • HTTPレスポンスを返したら、取得したトランザクションハンドルの後始末が行われている?
    • レスポンスを返したあと、各ノードからDatastoreに対して「このハンドルはもういらないから破棄」というようなことをやってるのではないか
    • 試行結果のトランザクションハンドル(数値)を眺めてみるとこんな感じで、数値が上下してることから、値の再利用は間違いなくやってる
      • 3329134779050992549
      • 3447372513697309927
      • 5384575290747572498
      • 17796629001013886752
      • 706364654572229175
      • 7448387133432909780
      • 14880868972903147936
      • 11879584442667429514
      • 7178209122181341880
      • 13880403251869159588
      • 以下略
    • 使用したハンドルの破棄をどのタイミングでやるかというと、一定時間おきだと処理数が爆発しそうだし、HTTPレスポンス返したらさくっと実施というのは十分に考えられる
      • ていうか普通そうするような気がするね

原因の予測としてはこんな感じで、どれにせよHTTPリクエストをまたいでトランザクションを継続するのはどうも無理そう、という結論に落ち着いた。

さてどうしようか

production環境でトランザクションは動かしたい。今のところ考えられる方法は以下のふたつ。

全テストをproduction環境内で動作させる

これは既にやってる人が(多分いっぱい)いて、PythonならGAEUnitが使える。多分。まだ試してないけど大丈夫でしょう。Javaならkotoriかな。
利点は確実に何の問題もなく動作すること。欠点はちょっとコード変えるたびにデプロイしないといけない*1のと、productionのCPU時間を食うこと。特定のテストだけ実行とかはできそうだったけど。多分。

トランザクション部分だけproduction環境で関数ごとevalする

marblejenkaさんはこっちを考えてるらしい。balmysundaycandy-scalaの改良方針だと思われる。
利点は手元でちょっと書いたコードを即座に試せること。特にremote_api_shellみたいな対話環境で定義したものが即座に動くというのは実にいい。

欠点は実現が難しそうなこと。
production環境でトランザクション処理対象となる関数をevalするときには、手元で定義したばかりのクラスや作成したオブジェクトなども問題なく扱えるよう、その定義文や代入などまで含めてevalしてやる必要がある。evalするためには適当な形式でくるんで送ってやらないといけない。毎回対話環境起動後の全処理を送ってやるのも馬鹿らしいから「前のトランザクション以降のすべて」とかやりたいが、トランザクション外のDatastoreアクセス処理(put/delete等)との整合性が取れるかが心配になってくる。
あと特にpython側ではTransaction内/外の検出をPythonインタプリタの実行フレームから取得したりしてるので、このあたりの処理に不整合が起きないかも注意する必要がありそう。

うーむ

なかなか悩ましいですね。まあ「どうしてもproduction環境のTransaction処理じゃないとイヤだ!」とか言わなければ、remote_api_shell.pyから使える普通のトランザクション(RemoteDatastoreStubで処理される疑似トランザクション)でいいような気もする。他との更新衝突があれば普通に検出してくれるしね。改めて読んでみると、ちゃんと考えて作ってあるなーと思う。*2

Javaにはそういうの無いんだっけ? RemoteDatastoreStubのクローンを作るというのはいい手だと思う、が、クライアント/サーバ両側を移植しないといかんからけっこう大変かも。うーむ。

*1:毎回数十秒かかるのでちょっとイラつく

*2:逆に、なんでDatastoreFileStubはあんなに手抜きなの、という……。Googlerは常に本物のDatastoreいじってるからモチベーションないのかな。