2012年2月11日土曜日

java : SQLiteではStatement.cancelが効かない

jdkのドキュメントを見ると、Statement.cancelメソッドで実行中のSQL文を停止できるようです。 ただし、apiリファレンスには「DBMSおよびドライバの両方がSQL文の終了をサポートする場合に、このStatementオブジェクトを取り消します。」との記述があります。 サポートしていない場合はSQLFeatureNotSupportedExceptionが投げられる模様。

SQLite + SQLiteJDBCでcancelができるか試してみました。 SQLiteJDBCの入手先は、

試したコードはこんな感じです。 まずはデータベースを扱うクラス。 Statement.cancelメソッドは別スレッドから停止させるためのものなので、Runnableを実装しています。 メインスレッド以外にデータベース処理用のスレッドを作り、selectが動いたらメインスレッドから停止させます。

// Database.java
package sqlitestatementclosetest;

import java.io.PrintStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.Statement;
import java.util.Random;
import org.sqlite.Function;

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

    @Override public void run()
    {
        PrintStream out = System.out;
        Connection c = null;
        Random r = new Random(System.currentTimeMillis() );
        try
        {
            out.println("◆データベースの用意");
            Class.forName("org.sqlite.JDBC");
            c = DriverManager.getConnection("jdbc:sqlite:" + DB_NAME);
            c.setAutoCommit(false);

            String sqlDropTable = "drop table if exists tmain";
            String sqlCreateTable = "create table tmain (col1 integer, col2 text)";
            String[] col2s = {"住宅", "アパート", "豪邸", "道路", "高速道路", "信号", "線路", "踏み切り", "バス停", "商店", "スーパー", "デパート", "アンテナショップ", "地下街", "駅", "田んぼ", "工場", "町工場", "発電所", "空港", "漁港", "灯台"};

            _st = c.createStatement();
            _st.executeUpdate(sqlDropTable);
            _st.executeUpdate(sqlCreateTable);
            PreparedStatement ps = c.prepareStatement(
                    "insert into tmain values (?, ?)"
            );

            for(int i = 0; i < 10000; i++)
            {
                ps.setInt(1, r.nextInt() );
                ps.setString(2, "" + Math.abs(r.nextInt() ) + "番目の" + col2s[i % col2s.length] );
                ps.executeUpdate();
            }
            c.commit();

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

            out.println("◆ゆっくりとselect");
            out.println("  !エンターキーを押すと停止");
            ResultSet res = _st.executeQuery(
                    "select ufSabotage(col1) as col1s, col2 from tmain"
            );
            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);
                }
            }
        }
    }

    public void stop()
    {
        assert _st != null;
        try
        {
            _st.cancel();
        }
        catch (SQLException exc)
        {
            printSqlException("Statementのcancel失敗", exc);
        }
    }

    private void printException(String text, Exception exc)
    {
        StringBuilder sb = new StringBuilder(text);
        sb.append("\n\t");
        sb.append(exc.getMessage() );
        sb.append('\n');

        for(StackTraceElement elem : exc.getStackTrace() )
            sb.append('\t').append(elem.toString() ).append('\n');

        System.err.println(sb);
    }

    private void printSqlException(String text, SQLException exc)
    {
        StringBuilder sb = new StringBuilder(text);
        sb.append("\n\t");
        sb.append(exc.getMessage() );
        sb.append("\n\t");
        sb.append("code = ");
        sb.append(exc.getErrorCode() );
        sb.append("\n\t");
        sb.append("state = ");
        sb.append(exc.getSQLState() );
        sb.append('\n');
        for(StackTraceElement elem : exc.getStackTrace() )
            sb.append('\t').append(elem.toString() ).append('\n');

        System.err.println(sb);
    }
}

メインスレッドはデータベースのスレッドを実行してからエンターキー待ち。 エンターキーが押されたらcancelを呼び出します。

// SqliteStatementCloseTest.java
package sqlitestatementclosetest;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class SqliteStatementCloseTest
{
    public static void main(String[] args)
    {
        Database db = new Database();
        Thread th = new Thread(db);
        th.start();

        WaitEnterKey();
        db.stop();
        System.out.println("Stopメソッドを呼びました。");

        try
        {
            th.join();
        }
        catch (InterruptedException exc)
        {
            exc.printStackTrace();
        }
        System.out.println("プログラム終了。");
    }

    public static void WaitEnterKey()
    {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        try
        {
            br.readLine();
        }
        catch(IOException exc)
        {
            exc.printStackTrace();
        }
    }
}

ufSabotageはselectの処理時間をわざと遅くするユーザー定義関数です。 ユーザー定義関数の作り方については前の投稿を見てください。

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

public class SabotageFunction extends Function
{
    public String getName()
    {
        return "ufSabotage";
    }

    @Override
    protected void xFunc() throws SQLException
    {
        try
        {
            Thread.sleep(300);
        }
        catch (InterruptedException exc)
        {
            exc.printStackTrace();
        }
        result(value_int(0) );
    }
}

結果は次の通り、

◆データベースの用意
◆SabotageFunctionの登録
◆ゆっくりとselect
  !エンターキーを押すと停止
> 001 :  -1797261181,       613391689番目の住宅
> 002 :   1969736450,    1226649321番目のアパート
> 003 :  -1673489017,      1838990850番目の豪邸
> 004 :   -740095700,      1463971762番目の道路
← ここでエンターキーを押した
Stopメソッドを呼びました。
> 005 :   2024542586,    1395106411番目の高速道路
SQL例外
プログラム終了。
    [SQLITE_INTERRUPT]  Operation terminated by sqlite3_interrupt() (interrupted)
    code = 0
    state = null
    org.sqlite.DB.newSQLException(DB.java:383)
    org.sqlite.DB.newSQLException(DB.java:387)
    org.sqlite.DB.throwex(DB.java:374)
    org.sqlite.RS.next(RS.java:152)
    sqlitestatementclosetest.Database.run(Database.java:68)
    java.lang.Thread.run(Thread.java:722)

思っていたのと処理の様子がぜんぜん違いました。 select文が全部終わってからResultSetの処理に行くと思ったら、select文は即終了、ResultSetを取り出すところでウェイトがかかっています。 ResultSet.nextが呼ばれるタイミングでSabotageFunction.xFuncが呼ばれているんですね。 で、4行見たところでエンターを押したらSQLException(SQLITE_INTERRUPT)が発生しました。 (標準出力とエラー出力が混ざってしまったけど、それは本筋で無いので流しで。)

Statement.cancelメソッドはSQLiteJDBCでサポートされているようです。 で、cancelされると例外が投げられます。 そのときエラーコードやSQLStateには値が設定されないようです。 cancelと他の例外を場合分けするにはエラーメッセージの文字列比較をするしか無いようですね。

catch(SQLException exc)
{
    if(exc.getMessage().startsWith("[SQLITE_INTERRUPT]") )
    {
        System.out.print("cancelされました。");
    }
}

しかしすごく重いSQL文を実行したときに、はたしてcancelできるものかどうか? Statement.executeQueryを止めれるかどうか確かめたかったんだよなぁ...

で、思いつきました。 select文にorder byを付けたら、executeQueryが終わるまで先に進まないんじゃないですかね?

select ufSabotage(col1) as col1s, col2 from tmain order by col1

やってみたら思ったとおりでした。 ResultSetの表示の前でウェイトがかかります。 肝心のcancelは...?

◆データベースの用意
◆SabotageFunctionの登録
◆ゆっくりとselect
  !エンターキーを押すと停止
← ここでエンターキーを押した
Statementのcancel失敗
Stopメソッドを呼びました。
    ResultSet closed
    code = 0
    state = null
    org.sqlite.RS.checkOpen(RS.java:63)
    org.sqlite.Stmt.cancel(Stmt.java:254)
    sqlitestatementclosetest.Database.stop(Database.java:113)
    sqlitestatementclosetest.SqliteStatementCloseTest.main(SqliteStatementCloseTest.java:28)

失敗しました。 そっちは思ったとおりじゃない。 デバッガでSabotageFunction.xFuncにブレイクポイントを設定してみたら、データベース用のスレッドは元気に働いていました。 どうやらSQLiteJDBCでは重いsql文を途中で止めることはできないようです。 SQLFeatureNotSupportedExceptionは発行されないようなので「サポートされているけど失敗する」というしょっぱい状態でした。