2012年2月12日日曜日

java : SQLiteのselectを途中で止める

前のネタでSQLite + SQLiteJDBCでは重いselect文を実行してしまったときにStatement.cancelメソッドが効かず、途中で止められないことが判明しました。

しかし今回使っているSQLiteJDBCではユーザー定義関数を作ることができます。

これをうまく使えばselectの途中で例外を投げてクエリーを止めることができます。 というわけでこんなクラスを作ってみました。

package sqlitestatementclosetest;
import java.sql.SQLException;
import org.sqlite.Function;

public class SqliteUserFunctionChanceToStop extends Function
{
    public final long DEFAULT_CHECK_INTERVAL = 200;
    public final String STOPPED_MESSAGE = "SqliteUserFunctionChanceToStop : comfirmed stop methord.";

    private long _timeBefore;
    private boolean _exit;
    private long _checkInterval;

    public SqliteUserFunctionChanceToStop()
    {
        _timeBefore = 0;
        _exit = false;
        _checkInterval = DEFAULT_CHECK_INTERVAL;
    }

    public String getName()
    {
        return "ufChanceToStop";
    }

    public void SetCheckInterval(long interval)
    {
        _checkInterval = interval;
    }

    public synchronized void stop()
    {
        _exit = true;
    }

    @Override
    protected void xFunc() throws SQLException
    {
        long currentTime = System.currentTimeMillis();
        if(_checkInterval < (currentTime - _timeBefore) ) // synchronizedをしているのでチェックがあまりに頻繁にならないように
        {
            _timeBefore = currentTime;

            synchronized(this)
            {
                if(_exit)
                {
                    // 次に使うときのためにリセット
                    _timeBefore = 0;
                    _exit = false;
                    throw new SQLException(STOPPED_MESSAGE);

                    //errorメソッドでは例外の種類を特定できない?
                    //error(STOPPED_MESSAGE);
                }
            }
        }
        result(value_long(0) );
    }
}

これを試すのは、前のコードをちょっとかえて、

package sqlitestatementclosetest;

import 前と同じなので略...

public class Database implements Runnable
{
    private static final String DB_NAME = "d:/test.db";
    private Statement _st;
    private SqliteUserFunctionChanceToStop _ufChanceToStop;

    @Override public void run()
    {
        PrintStream out = System.out;
        Connection c = null;
        Random r = new Random(System.currentTimeMillis() );
        try
        {
            out.println("◆データベースの用意");
            前と同じなので略...

            out.println("◆SabotageFunctionの登録");
            前と同じなので略...

            out.println("◆SqliteUserFunctionChanceToStopの登録");
            _ufChanceToStop = new SqliteUserFunctionChanceToStop();
            Function.create(c, _ufChanceToStop.getName(), _ufChanceToStop);

            // これはロックを得るときのタイムアウトでselect文を途中で止める効果はない
            _st.setQueryTimeout(3 * 1000);

            out.println("◆ゆっくりとselect");
            out.println("  !エンターキーを押すと停止");
            ResultSet res = _st.executeQuery(
                    "select ufSabotage(col1) as col1s, col2 from tmain order by ufChanceToStop(col1)"
            );
            int line = 1;
            while(res.next() )
            {
                out.println(String.format(
                        "> %03d : %12d, %20s",
                        line,
                        res.getInt("col1s"),
                        res.getString("col2")
                ));
                line++;
            }
        }
        catch(ClassNotFoundException exc)
        {
            printException("jdbcドライバの読み込みエラー", exc);
        }
        catch(SQLFeatureNotSupportedException exc)
        {
            printSqlException("SQL例外(JDBCドライバがサポートしない機能を実行)", exc);
        }
        catch(SQLException exc)
        {
            printSqlException("SQL例外", exc);
        }
        finally
        {
            if(c != null)
            {
                try
                {
                    c.close();
                }
                catch (SQLException exc)
                {
                    printSqlException("SQLコネクションクローズ失敗", exc);
                }
            }
        }
        out.println("◆データベーススレッド終了");
    }

    public void stop()
    {
        assert _ufChanceToStop != null;
        _ufChanceToStop.stop();
    }

    private void printException(String text, Exception exc)
    {
        前と同じなので略...
    }

    private void printSqlException(String text, SQLException exc)
    {
        前と同じなので略...
    }
}

select文とstopメソッドの中身が少々変わっています。 実行結果はこんな感じ。

◆データベースの用意
◆SabotageFunctionの登録
◆SqliteUserFunctionChanceToStopの登録
◆ゆっくりとselect
  !エンターキーを押すと停止
← ここでエンターキーを押した
◆Stopメソッドを呼びました。
SQL例外
◆データベーススレッド終了
    [SQLITE_ERROR] SQL error or missing database (java.sql.SQLException: SqliteUserFunctionChanceToStop : comfirmed stop methord.)
◆プログラム終了。
    code = 0
    state = null
    org.sqlite.DB.newSQLException(DB.java:383)
    org.sqlite.DB.newSQLException(DB.java:387)
    org.sqlite.DB.execute(DB.java:342)
    org.sqlite.Stmt.exec(Stmt.java:65)
    org.sqlite.Stmt.executeQuery(Stmt.java:122)
    sqlitestatementclosetest.Database.run(Database.java:72)
    java.lang.Thread.run(Thread.java:722)

ちゃんと止まりました。

このコードを使う場合の注意点は3つ。 1つ目は「xFunc中に投げた例外メッセージの判別の仕方」です。

try
{
    ResultSet res =
            ステートメント.executeQuery(クエリー);
}
catch(SQLException exc)
{
    なんたら
}

とした場合、excの中に直接xFunc中に投げた例外は入りません。 先ほどの実行結果のとおり、exc.getMessageの中身は

[SQLITE_ERROR] SQL error or missing database
        (java.sql.SQLException: xFuncで投げた例外のメッセージ)

となっています。 SQLiteJDBCはjniを使っているので、その過程でこうなったのだと思われます。 中断したときの例外か別の例外かを見分けるときはexc.getMessageの文字列を調べる必要があります。

2つ目は、当然ながら「SqliteUserFunctionChanceToStop.xFuncが呼ばれない限りクエリーが止まる事はない」ということ。 クエリーへの仕込み方を間違えるとxFuncに処理が来ません。 order byやselect句に仕込んだ場合は行が見つかったときしかxFuncが呼ばれないので注意。 whereに上手く仕込めば毎回呼ばれる可能性はあるかもしれませんが、この辺はSQLite本体の最適化次第で変わってしまうかもしれません。 試してませんが、SQLiteはデータベースとしてはシンプルな作りなので、今のバージョンでは「クエリーのここに仕込めば毎回呼ばれる」というような工夫の仕方はあるかもしれないですね。 しかしそういうのはSQLiteのバージョンが上がると無意味になるかもしれません。 ChanceToStopという名前の通り「止める機会があるかも?」というくらいの期待感で使ってください。

3つ目は「クエリーによってxFuncが呼ばれるタイミングが違う」ということです。 これは前の投稿の通りです。 sql文の書き方によってStatement.executeQueryの時点で呼ばれる場合とResultSet.nextの時点で呼ばれる場合があります。 どちらで例外が発生してもいいようにコーディングしましょう。

...これでクエリーを途中で止めれるようにはなりました。 しかしなんというか、SQLiteというかSQLiteJDBCにべったりのコードですね。 ここまでしなくちゃならないくらいならSQLite以外の高機能なデータベースを選んだ方がいいのかも?