From e2bc38ce0cf3af4b7bdca70e21a30334da97d5e1 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:34:52 +0200 Subject: [PATCH 1/2] fix(jdbc): stop batches at the first failure and throw BatchUpdateException (#104) --- .../magnusp/libsql/jdbc/LibsqlConnection.java | 28 ++-- .../libsql/jdbc/LibsqlConnectionTest.java | 142 ++++++++++++++++++ 2 files changed, 161 insertions(+), 9 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java index 7dec0c0..d51c8d7 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java @@ -4,7 +4,9 @@ import com.github.magnusp.libsql.client.LibsqlException; import com.github.magnusp.libsql.client.LibsqlHttpClient; import com.github.magnusp.libsql.hrana.codec.Batch; +import com.github.magnusp.libsql.hrana.codec.BatchCond; import com.github.magnusp.libsql.hrana.codec.BatchStep; +import com.github.magnusp.libsql.hrana.codec.HranaError; import com.github.magnusp.libsql.hrana.codec.PipelineReqBody; import com.github.magnusp.libsql.hrana.codec.PipelineRespBody; import com.github.magnusp.libsql.hrana.codec.Stmt; @@ -15,6 +17,7 @@ import java.net.URI; import java.sql.Array; +import java.sql.BatchUpdateException; import java.sql.Blob; import java.sql.ResultSet; import java.sql.CallableStatement; @@ -32,6 +35,7 @@ import java.sql.Statement; import java.sql.Struct; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Properties; @@ -135,8 +139,9 @@ public int[] executeBatch(List statements) throws SQLException { try { List steps = new ArrayList<>(statements.size()); - for (Stmt stmt : statements) { - steps.add(new BatchStep(stmt)); + for (int i = 0; i < statements.size(); i++) { + BatchCond condition = i == 0 ? null : new BatchCond.StepOk(i - 1); + steps.add(new BatchStep(condition, statements.get(i))); } Batch batch = new Batch(steps); @@ -175,14 +180,19 @@ private int[] extractBatchResults(PipelineRespBody resp, int expectedSize) throw int[] updateCounts = new int[expectedSize]; for (int i = 0; i < expectedSize; i++) { - if (batchResp.result().stepErrors().containsKey(i)) { - updateCounts[i] = Statement.EXECUTE_FAILED; - } else if (batchResp.result().stepResults().containsKey(i)) { - StmtResult r = batchResp.result().stepResults().get(i); - updateCounts[i] = (int) r.affectedRowCount(); - } else { - updateCounts[i] = Statement.SUCCESS_NO_INFO; + HranaError error = batchResp.result().stepErrors().get(i); + if (error != null) { + // JDBC stops the batch at the first failure and reports only the steps that ran. + throw new BatchUpdateException(error.message(), Arrays.copyOf(updateCounts, i)); + } + StmtResult result = batchResp.result().stepResults().get(i); + if (result == null) { + // A skipped step is legitimate only after a failed step, which the loop has + // already thrown on. Here the server executed the step but reported nothing. + throw new SQLException( + "Protocol error: batch step " + i + " has neither a result nor an error"); } + updateCounts[i] = (int) result.affectedRowCount(); } return updateCounts; } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java index 16a9108..4a9b063 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java @@ -3,6 +3,9 @@ import com.github.magnusp.libsql.client.LibsqlClientConfig; import com.github.magnusp.libsql.client.LibsqlException; import com.github.magnusp.libsql.client.LibsqlHttpClient; +import com.github.magnusp.libsql.hrana.codec.BatchCond; +import com.github.magnusp.libsql.hrana.codec.BatchResult; +import com.github.magnusp.libsql.hrana.codec.BatchStep; import com.github.magnusp.libsql.hrana.codec.HranaError; import com.github.magnusp.libsql.hrana.codec.PipelineReqBody; import com.github.magnusp.libsql.hrana.codec.PipelineRespBody; @@ -13,13 +16,17 @@ import com.github.magnusp.libsql.hrana.codec.StreamResult; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import java.net.URI; +import java.sql.BatchUpdateException; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @@ -31,6 +38,7 @@ class LibsqlConnectionTest { private static final String BATON = "baton-1"; private static final String SERVER_ERROR = "transaction end failed on server"; private static final String TRANSPORT_ERROR = "Connection reset by peer"; + private static final String BATCH_FAILURE = "UNIQUE constraint failed: sample.id"; @Test void getMetaDataIsCachedPerConnection() throws SQLException { @@ -197,6 +205,140 @@ void transportErrorIsPropagatedAndTransactionStateIsKept(String command) throws assertCommandRetriedOnSameStream(client, command); } + @ParameterizedTest(name = "fail at step {0}, autoCommit={1}") + @CsvSource({"0,true", "1,true", "2,true", "0,false", "1,false", "2,false"}) + void batchStopsAtFirstFailureAndThrowsWithTheCountsOfStepsThatRan(int failAt, boolean autoCommit) + throws SQLException { + List executed = new ArrayList<>(); + ScriptedClient client = new ScriptedClient( + (request, index) -> batchResponse(request, failAt, executed)); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.setAutoCommit(autoCommit); + List sql = List.of( + "INSERT INTO sample VALUES (1)", + "INSERT INTO sample VALUES (2)", + "INSERT INTO sample VALUES (3)"); + + assertThatThrownBy(() -> connection.executeBatch(sql.stream().map(Stmt::new).toList())) + .isInstanceOf(BatchUpdateException.class) + .hasMessageContaining(BATCH_FAILURE) + .satisfies(thrown -> assertThat(((BatchUpdateException) thrown).getUpdateCounts()) + .containsExactly(countsOfStepsThatRan(failAt))); + + assertThat(executed).containsExactlyElementsOf(sql.subList(0, failAt)); + + List steps = batchSteps(client); + assertThat(steps).hasSize(sql.size()); + assertThat(steps.get(0).condition()).isNull(); + for (int i = 1; i < steps.size(); i++) { + assertThat(steps.get(i).condition()) + .as("step %d runs only when step %d succeeded", i, i - 1) + .isEqualTo(new BatchCond.StepOk(i - 1)); + } + } + + @Test + void successfulBatchReturnsOneCountPerStep() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> batchResponse(request, -1, new ArrayList<>())); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + + int[] counts = connection.executeBatch(List.of( + new Stmt("INSERT INTO sample VALUES (1)"), + new Stmt("INSERT INTO sample VALUES (2)"), + new Stmt("INSERT INTO sample VALUES (3)"))); + + assertThat(counts).containsExactly(1, 1, 1); + } + + @Test + void batchStepWithNeitherResultNorErrorIsAProtocolError() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> responseWithMissingBatchStep(request)); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + + assertThatThrownBy(() -> connection.executeBatch(List.of( + new Stmt("INSERT INTO sample VALUES (1)"), + new Stmt("INSERT INTO sample VALUES (2)")))) + .isInstanceOf(SQLException.class) + .isNotInstanceOf(BatchUpdateException.class) + .hasMessageContaining("neither a result nor an error"); + } + + private static int[] countsOfStepsThatRan(int failAt) { + int[] counts = new int[failAt]; + for (int i = 0; i < counts.length; i++) { + counts[i] = 1; + } + return counts; + } + + private static List batchSteps(ScriptedClient client) { + return client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.BatchReq.class::isInstance) + .map(StreamRequest.BatchReq.class::cast) + .findFirst() + .map(batchReq -> batchReq.batch().steps()) + .orElseThrow(); + } + + private static PipelineRespBody batchResponse(PipelineReqBody request, int failAt, List executed) { + List results = new ArrayList<>(); + for (StreamRequest streamRequest : request.requests()) { + if (streamRequest instanceof StreamRequest.BatchReq batchReq) { + results.add(new StreamResult.Ok(new StreamResponse.BatchResp( + runBatch(batchReq.batch().steps(), failAt, executed)))); + } else if (streamRequest instanceof StreamRequest.Close) { + results.add(new StreamResult.Ok(StreamResponse.Close.INSTANCE)); + } else { + results.add(new StreamResult.Ok( + new StreamResponse.Execute(new StmtResult(List.of(), List.of(), 0, null)))); + } + } + return new PipelineRespBody(null, null, results); + } + + private static BatchResult runBatch(List steps, int failAt, List executed) { + Map stepResults = new HashMap<>(); + Map stepErrors = new HashMap<>(); + for (int i = 0; i < steps.size(); i++) { + if (!conditionHolds(steps.get(i).condition(), stepResults)) { + continue; + } + if (i == failAt) { + stepErrors.put(i, new HranaError(BATCH_FAILURE, "SQLITE_CONSTRAINT")); + } else { + executed.add(steps.get(i).stmt().sql()); + stepResults.put(i, new StmtResult(List.of(), List.of(), 1, null)); + } + } + return new BatchResult(stepResults, stepErrors); + } + + private static boolean conditionHolds(BatchCond condition, Map stepResults) { + if (condition == null) { + return true; + } + if (condition instanceof BatchCond.StepOk ok) { + return stepResults.containsKey(ok.step()); + } + throw new AssertionError("Unexpected batch condition: " + condition); + } + + private static PipelineRespBody responseWithMissingBatchStep(PipelineReqBody request) { + List results = new ArrayList<>(); + for (StreamRequest streamRequest : request.requests()) { + if (streamRequest instanceof StreamRequest.BatchReq) { + Map stepResults = new HashMap<>(); + stepResults.put(0, new StmtResult(List.of(), List.of(), 1, null)); + results.add(new StreamResult.Ok(new StreamResponse.BatchResp( + new BatchResult(stepResults, Map.of())))); + } else { + results.add(new StreamResult.Ok(StreamResponse.Close.INSTANCE)); + } + } + return new PipelineRespBody(null, null, results); + } + private static void assertCommandRetriedOnSameStream(ScriptedClient client, String command) { List commandRequests = client.requests.stream() .filter(request -> command.equals(executedSql(request))) From efe83633df5092c511a093c59ae6376039f306fb Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:41:11 +0200 Subject: [PATCH 2/2] refactor(jdbc): drop the unused BatchUpdateException import (#104) --- .../java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java | 1 - 1 file changed, 1 deletion(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java index 98c4682..973af11 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java @@ -2,7 +2,6 @@ import com.github.magnusp.libsql.client.LibsqlException; -import java.sql.BatchUpdateException; import java.sql.SQLClientInfoException; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException;