日々常々

ふつうのプログラマがあたりまえにしたいこと。

oo4o+VB.NETの注意点(オープンカーソル)

JavaCloudMeetingで熱くなってるところに冷や水をぶっ掛ける自虐プレイ的に今日はVB.NETの記事です。
VB.NETで作られたASP.NETのWebアプリケーションでデータベース接続にoo4oを使用するお話。このご時勢にVB.NETのWebアプリケーションとか都市伝説とか思わないでください。あるんです。詳細を書くと自分の首を絞める事になるのでやめときます。

さてVB.NET+oo4oで詰まった所を晒してやる。頼りになる情報はOracle Objects for OLE開発者ガイドです。うっかり検索しても、VBVBAの話が出てくるからあんまり意味ないです。

直面した問題

「ORA-01000 最大オープン・カーソル数を超えました」
こんなものがWebアプリケーションで出てきたら、普通はバグです。普通に考えてカーソルのクローズ漏れです。出たときはものすっごく気楽に考えてました。この後の罠なんて知る由もありません。

ちょっとJavaのはなし

JavaJDBC 使ってて ResultSet#close を呼んでない腐ったプログラムはいくらでも見てきています。Javaデータアクセスの基礎(2):JDBCによるDBへの接続と検索の実行 - @ITとかもそうです。コードだけ抜粋しますが、これを"基礎"とか言って公開することは害悪だと認識してほしい。これをのままコピペって「出来ました!」とか言うのがコの業界ではPG*1ってのなんだから。

// 略
  ResultSet rset = stmt.executeQuery("select EMPNO, ENAME from EMP");
  while ( rset.next() ) {
    System.out.println(rset.getInt(1) + "\t" + rset.getString(2));
  } 
  rset.close(); 
  stmt.close(); 
  conn.close();
// 略

ひどいもんです。もっと酷いのはこれ。

 これらのオブジェクトは、ガベージ・コレクションされる際に、自動的にクローズされ、データベースやJDBCのリソースを解放します。ですが、以下のリストのように、オブジェクトが不要になった時点で、明示的にクローズしリソースを直ちに解放する、という習慣を身に付けておく方がいいでしょう。

GCが動かなきゃ永久にクローズされません。GCなんていつ動くの?そんな不確かなものに頼らないとカーソル開きっぱなしなんだぜ。せめて実行くらいはちゃんとしようよ…。

oo4oではカーソル≒OraDynaset

脱線した。えーと、oo4oのお話。oo4oでのJavaで言う ResultSet 、つまりOracleのカーソルに当たるものは OraDynaset になります。この OraDynaset オブジェクトは読み取りだけじゃなく書き込みも出来たりするんですが、なんかしっくり来ないので私は使いたくありません。更新はDMLでやってほしい。OraDynasetオブジェクトの概要に書いてる例はこんな感じですね。これにはあまり違和感ありません。

Set employees = OraDatabase.CreateDynaset("select empno, ename from emp", 0&)

しかし例にはクローズなんて書いてない。しかしCloseメソッドは書いてる場所もある。どういうこっちゃと思ってガイドを見てみたら…

Closeメソッド

説明
このメソッドは何も実行しません。Visual Basicとの互換性を保つために追加されています。

備考
OraDatabaseオブジェクトとOraDynasetオブジェクトは、いずれもこのメソッドをサポートしません。OraDatabaseまたはOraDynasetオブジェクトが有効範囲を超えると、参照する対象がなくなり、オブジェクトは自動的にクローズします。

Closeメソッド

何を言っているのか判らない。「何も実行しない」だと?なるほど、Close呼んでてもV$OPEN_CURSORSみたら開きっぱなしなわけだ!ってなんだよそれ…Close漏れがあるのかと思って必死に追加した私の苦労をどうしてくれる(あまりに面倒だったからやらなかったけど)。

そんなわけで、OraDynaset は Close を呼んでもスルーです。カーソルは閉じません。カーソル閉じてほしかったら有効範囲を超えて参照する対象が無くなる=GCで回収されるのを待てってこと。だから、普通に動かしていても最大オープンカーソル数なんてあっさり超えかねません。こんなん見せられると、ResultSet#closeも空振りしてるんじゃないかと不安になってくる。いやでもあっちはcloseいれたらカーソル数超えなかったし…。

対処法

しかし業務アプリケーションは今日も誤魔化しながら動き続けます。せめてオープンカーソル数オーバーのエラーくらいは無くしたい。どうするか。とりあえず3つ思い浮かびました。

  • がんばって閉じる
  • 最大オープンカーソル数を増やす
  • 超えそうだったら接続を切り替える

普通に考えると「がんばって閉じる」でしょう。がんばりましょう。自動で閉じるって事はファイナライザでしょう。VB.NETのファイナライザはProtectedとなっており、外部からのアクセスは出来ません。ですので呼べません。リフレクション的なものがあって、やろうと思えば出来るのかもしれませんが、それもどうなのよと。
ファイナライザはGCが動いたら呼ばれる感じです。つまりGCの対象にしなければなりません。VB.NETのプログラムは駄目なほうのVB文化を受け継いでいる可能性があります。例えばグローバル変数使いまくっていて、下手すればOraDynasetを格納する変数がグローバルなんていうような。とにかくスコープってものを考えていないやつ。
GCの対象となるためには、参照されていない到達不可な状態が必要です。つまり OraDynaset を格納した変数が、使い終わった後に参照できていてはいけません。VB的には変数にNothingを突っ込むとかになりますが、ガイドには「有効範囲を超えると」とありますので、メソッドに切り出すのがいいと思います*2メソッドに切り出す事でスコープを狭めます。試せば判りますが、これでメソッドを抜けたとはガイドに書かれてる通りに「有効範囲を超え」て「参照する対象がなくな」った状態になりますが、クローズはしません。
ではGCを実行します。VB.NETGC実行は1行で一応できます。一応。詳細はMSDNのGC.Collectに書いてある通りです。

Sub test()
  For i As Integer = 0 To 100
    openEmployees
  Next
  'GCを実行
  System.GC.Collect()
End Sub

Sub openEmployees()
  Dim employees As OraDynaset
  employees = OraDatabase.CreateDynaset("select empno, ename from emp", 0&)
End Sub

カーソルをいっぱい使う処理が終わった後にGC.Collectを呼ぶ。これである程度閉じられる事を期待します。GC.Collectの説明を見ると、「GCは強制的に実行されるが、全て片付けられるとは限らない」らしい。上述のtestを呼び出す別のループをさらにループさせて実行してカーソル数を眺めてると増えたり減ったりします。 0→105→20→130→45→160→30→… といった感じになり、100が上限にはなってくれません。あくまでもGCを実行するだけで、回収するかどうかは運任せになります。また、GCを実行してもファイナライザの実行は別のスレッドで行われるので、ファイナライザ処理中に新しいカーソルをどんどん開いていったりします。ファイナライザの完了を待つための System.GC.WaitForPendingFinalizers メソッドがあるので必要に応じて使うといいかもしれません。
とりあえずがんばって閉じる方法はGCの対象とするようにして、強制実行になります。ただし、どこまで行っても運任せになります。

次の「最大オープンカーソル数を増やす」ですが、これはOracle側の設定です。Oracle10gでは最大オープンカーソル数のデフォルト値は300であり、通常これは超えない数字だと思います。でも起こっちゃったからこう対処しないといけないわけで、アプリケーションの上限を見極めて対応するのはいい手かもしれません。ファイナライザさえ実行されればカーソルは閉じられますので、1処理内で生成されるDynase数を計測した上で対処するといいでしょう。私が直面した問題では数百とか生易しい問題じゃなく、ン十万回のSQLが発行(=Dynasetの生成)が行われていましたのでこの対処は無しです。そんな設計自体どうよと言う感じなのですが。

最後の「超えそうだったら接続を切り替える」ですが、これをしなきゃいけないとなるとよっぽどおかしいです。おかしいけど、直面している問題だけは解決できかねません。勿論、同一トランザクションである必要があったりすれば使えませんが。やり方は簡単で、CreateDynasetの実行回数をカウントして、上限近くなったら別のセッションで再接続です。oo4oのOracleでいうセッションは OraSession では無く OraDatabase になります。と言うことで今まで使ってた OraDatabase をClose…はい、予想できてるかもしれませんが、この Close も OraDynaset と同じく何もしません。やったね!って接続どうやって閉じるの。これも予想通りGCによるファイナライザです。てことで今までの接続の参照を破棄して、新しく接続を作る事になります。今まで使ってた接続はGCに回収される事を祈ります。プール使ってたらどうなるかはちょっと判りません。許してください。
兎に角、オープンカーソル数=CreateDynaset数と見立てて上限値を設定し、超えそうなら新しい接続に乗り換える。当然トランザクション制御は無理ですのでそこは諦める形。これならとりあえずは最大カーソル数を超える事は無いでしょう。次は最大セッション数にぶち当たるかもしれませんが。



今回はカーソルの話だけでとりあえずおしまい。問題はまだまだありますが、やたら長くなったので次の機会にします。VB.NET+oo4oはWebアプリケーションに向いていないとか思ってくれると良いかも。

*1:PGはぴーじー。非プログラマ

*2:対象の変数をパブリックからローカルに変えるとか、挙動が変わるのでリファクタリングメソッド抽出にはなりません。もっとも正常系の挙動は変わらないでしょうが。