Skip to content

Commit fb7260f

Browse files
committed
fix(jdbc): match xerial transaction begin and re-begin lifecycle
Closes #40. Part of #54. setAutoCommit(false) now begins eagerly in the configured transaction mode (DEFERRED by default), and commit/rollback begin the next transaction in the same pipeline. Re-enabling auto-commit commits and closes the Hrana stream.
1 parent fca0f14 commit fb7260f

3 files changed

Lines changed: 370 additions & 28 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.github.magnusp.libsql.itest;
2+
3+
import com.github.magnusp.libsql.jdbc.LibsqlConnection;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.sql.Connection;
7+
import java.sql.DriverManager;
8+
import java.sql.SQLException;
9+
import java.sql.Statement;
10+
import java.util.concurrent.ExecutorService;
11+
import java.util.concurrent.Executors;
12+
import java.util.concurrent.Future;
13+
import java.util.concurrent.TimeUnit;
14+
import java.util.concurrent.TimeoutException;
15+
16+
import static org.assertj.core.api.Assertions.assertThat;
17+
18+
public class TransactionLifecycleIT extends SqldContainerBase {
19+
20+
private String getDriverJdbcUrl() {
21+
return "jdbc:libsql:" + getHttpUrl();
22+
}
23+
24+
@Test
25+
void deferredTransactionBeginsEagerlyWithoutHoldingAWriteLock() throws Exception {
26+
String url = getDriverJdbcUrl();
27+
try (Connection lockHolder = DriverManager.getConnection(url);
28+
Connection writer = DriverManager.getConnection(url)) {
29+
createTable(lockHolder, "deferred_lock");
30+
31+
lockHolder.setAutoCommit(false);
32+
33+
ExecutorService executor = Executors.newSingleThreadExecutor();
34+
try {
35+
Future<Integer> insert = executor.submit(() -> insertRow(writer, "deferred_lock", 1));
36+
assertThat(insert.get(5, TimeUnit.SECONDS)).isEqualTo(1);
37+
} finally {
38+
executor.shutdownNow();
39+
}
40+
}
41+
}
42+
43+
@Test
44+
void immediateTransactionBeginsEagerlyAndHoldsTheWriteLock() throws Exception {
45+
String url = getDriverJdbcUrl();
46+
try (Connection lockHolder = DriverManager.getConnection(url)) {
47+
createTable(lockHolder, "immediate_lock");
48+
LibsqlConnection libsql = lockHolder.unwrap(LibsqlConnection.class);
49+
libsql.setTransactionMode(LibsqlConnection.TransactionMode.IMMEDIATE);
50+
51+
lockHolder.setAutoCommit(false);
52+
53+
assertWriteIsBlockedUntilReleased(url, lockHolder, "immediate_lock", 1);
54+
}
55+
}
56+
57+
@Test
58+
void commitBeginsTheNextTransactionAndKeepsTheConfiguredLock() throws Exception {
59+
String url = getDriverJdbcUrl();
60+
try (Connection lockHolder = DriverManager.getConnection(url);
61+
Statement statement = lockHolder.createStatement()) {
62+
createTable(lockHolder, "commit_rebegin");
63+
LibsqlConnection libsql = lockHolder.unwrap(LibsqlConnection.class);
64+
libsql.setTransactionMode(LibsqlConnection.TransactionMode.IMMEDIATE);
65+
lockHolder.setAutoCommit(false);
66+
statement.executeUpdate("INSERT INTO commit_rebegin (id) VALUES (100)");
67+
68+
lockHolder.commit();
69+
70+
assertWriteIsBlockedUntilReleased(url, lockHolder, "commit_rebegin", 1);
71+
}
72+
}
73+
74+
private static void assertWriteIsBlockedUntilReleased(
75+
String url, Connection lockHolder, String table, int id) throws Exception {
76+
ExecutorService executor = Executors.newSingleThreadExecutor();
77+
try {
78+
Future<Object> insert = executor.submit(() -> {
79+
try (Connection writer = DriverManager.getConnection(url)) {
80+
return insertRow(writer, table, id);
81+
} catch (SQLException e) {
82+
return e;
83+
}
84+
});
85+
86+
Object resultWhileLocked;
87+
try {
88+
resultWhileLocked = insert.get(300, TimeUnit.MILLISECONDS);
89+
assertThat(resultWhileLocked)
90+
.as("writer must not succeed while the write lock is held")
91+
.isNotEqualTo(1);
92+
} catch (TimeoutException blocked) {
93+
resultWhileLocked = null;
94+
}
95+
96+
lockHolder.setAutoCommit(true);
97+
98+
if (insert.isDone()) {
99+
Object completed = insert.get();
100+
if (completed instanceof SQLException) {
101+
try (Connection writer = DriverManager.getConnection(url)) {
102+
assertThat(insertRow(writer, table, id)).isEqualTo(1);
103+
}
104+
} else {
105+
assertThat(completed).isEqualTo(1);
106+
}
107+
} else {
108+
assertThat(insert.get(20, TimeUnit.SECONDS)).isEqualTo(1);
109+
}
110+
} finally {
111+
executor.shutdownNow();
112+
}
113+
}
114+
115+
private static void createTable(Connection connection, String table) throws SQLException {
116+
try (Statement statement = connection.createStatement()) {
117+
statement.execute("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)");
118+
}
119+
}
120+
121+
private static int insertRow(Connection connection, String table, int id) throws SQLException {
122+
try (Statement statement = connection.createStatement()) {
123+
return statement.executeUpdate("INSERT INTO " + table + " (id) VALUES (" + id + ")");
124+
}
125+
}
126+
}

‎libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java‎

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,35 @@ public class LibsqlConnection implements Connection {
4646
private boolean readOnly = false;
4747
private int transactionIsolation = TRANSACTION_SERIALIZABLE;
4848
private boolean closed = false;
49+
private TransactionMode transactionMode = TransactionMode.DEFERRED;
50+
51+
/**
52+
* Transaction begin mode, matching xerial sqlite-jdbc's
53+
* {@code SQLiteConfig.TransactionMode}.
54+
*/
55+
public enum TransactionMode {
56+
DEFERRED("BEGIN"),
57+
IMMEDIATE("BEGIN IMMEDIATE"),
58+
EXCLUSIVE("BEGIN EXCLUSIVE");
59+
60+
private final String beginSql;
61+
62+
TransactionMode(String beginSql) {
63+
this.beginSql = beginSql;
64+
}
65+
66+
String beginSql() {
67+
return beginSql;
68+
}
69+
70+
public static TransactionMode from(String mode) {
71+
try {
72+
return valueOf(mode.toUpperCase());
73+
} catch (RuntimeException e) {
74+
throw new IllegalArgumentException("Unknown transaction mode: " + mode, e);
75+
}
76+
}
77+
}
4978

5079
// Interactive stream state
5180
private String baton = null;
@@ -159,15 +188,33 @@ private int[] extractBatchResults(PipelineRespBody resp, int expectedSize) throw
159188

160189
private void ensureTransactionStarted() throws SQLException {
161190
if (baton == null) {
162-
// Mitigation for Bug 2: Read-write transactions start with BEGIN IMMEDIATE
163-
String beginSql = readOnly ? "BEGIN DEFERRED" : "BEGIN IMMEDIATE";
164-
PipelineReqBody req = new PipelineReqBody(null, List.of(new StreamRequest.Execute(new Stmt(beginSql))));
165-
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
166-
updateStreamState(resp);
167-
if (resp.results().isEmpty() || resp.results().get(0) instanceof StreamResult.Error) {
168-
closeStreamSilently();
169-
throw new SQLException("Failed to begin transaction");
170-
}
191+
beginTransaction();
192+
}
193+
}
194+
195+
private void beginTransaction() throws SQLException {
196+
PipelineRespBody resp;
197+
try {
198+
PipelineReqBody req = new PipelineReqBody(null,
199+
List.of(new StreamRequest.Execute(new Stmt(transactionMode.beginSql()))));
200+
resp = client.sendPipeline(req, baseUrl);
201+
} catch (LibsqlException e) {
202+
throw LibsqlErrorMapper.toSQLException(e);
203+
}
204+
updateStreamState(resp);
205+
if (resp.results().isEmpty()) {
206+
closeStreamSilently();
207+
throw new SQLException("Empty result received from server");
208+
}
209+
StreamResult result = resp.results().get(0);
210+
if (result instanceof StreamResult.Error error) {
211+
closeStreamSilently();
212+
throw new SQLException(error.error().message());
213+
}
214+
if (!(result instanceof StreamResult.Ok ok)
215+
|| !(ok.response() instanceof StreamResponse.Execute)) {
216+
closeStreamSilently();
217+
throw new SQLException("Unexpected response: " + result);
171218
}
172219
}
173220

@@ -214,15 +261,33 @@ public String nativeSQL(String sql) throws SQLException {
214261
return sql;
215262
}
216263

264+
/**
265+
* Matches xerial sqlite-jdbc 3.53.4.0:
266+
*
267+
* <ul>
268+
* <li>Disabling auto-commit immediately begins a transaction in the configured
269+
* {@link TransactionMode}. The default is {@link TransactionMode#DEFERRED}, so an idle
270+
* non-auto-commit connection holds no lock until its first statement.
271+
* <li>{@link #commit()} and {@link #rollback()} end the current transaction and immediately
272+
* begin the next one, so a non-auto-commit connection always has a transaction open.
273+
* <li>Re-enabling auto-commit commits the open transaction and ends the Hrana transaction
274+
* stream.
275+
* </ul>
276+
*
277+
* <p>Use {@link #setTransactionMode(TransactionMode)} to select {@code IMMEDIATE} or {@code
278+
* EXCLUSIVE}, which acquire the write lock when the transaction begins.
279+
*/
217280
@Override
218281
public void setAutoCommit(boolean autoCommit) throws SQLException {
219282
checkClosed();
220283
if (this.autoCommit == autoCommit) return;
221-
if (!this.autoCommit && autoCommit) {
222-
// Committing active transaction
223-
commit();
284+
if (autoCommit) {
285+
endTransaction("COMMIT", false);
286+
this.autoCommit = true;
287+
} else {
288+
this.autoCommit = false;
289+
beginTransaction();
224290
}
225-
this.autoCommit = autoCommit;
226291
}
227292

228293
@Override
@@ -237,7 +302,7 @@ public void commit() throws SQLException {
237302
if (autoCommit) {
238303
throw new SQLException("Cannot commit when in autoCommit mode");
239304
}
240-
endTransaction("COMMIT");
305+
endTransaction("COMMIT", true);
241306
}
242307

243308
@Override
@@ -246,18 +311,26 @@ public void rollback() throws SQLException {
246311
if (autoCommit) {
247312
throw new SQLException("Cannot rollback when in autoCommit mode");
248313
}
249-
endTransaction("ROLLBACK");
314+
endTransaction("ROLLBACK", true);
250315
}
251316

252-
private void endTransaction(String sql) throws SQLException {
317+
private void endTransaction(String sql, boolean rebegin) throws SQLException {
253318
if (baton == null) {
319+
if (rebegin) {
320+
throw new SQLException("No transaction is active");
321+
}
254322
return;
255323
}
256324

325+
List<StreamRequest> requests = new ArrayList<>(2);
326+
requests.add(new StreamRequest.Execute(new Stmt(sql)));
327+
if (rebegin) {
328+
requests.add(new StreamRequest.Execute(new Stmt(transactionMode.beginSql())));
329+
}
330+
257331
PipelineRespBody resp;
258332
try {
259-
PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(new Stmt(sql))));
260-
resp = client.sendPipeline(req, baseUrl);
333+
resp = client.sendPipeline(new PipelineReqBody(baton, requests), baseUrl);
261334
} catch (LibsqlException e) {
262335
throw LibsqlErrorMapper.toSQLException(e);
263336
}
@@ -274,10 +347,33 @@ private void endTransaction(String sql) throws SQLException {
274347
throw new SQLException("Unexpected response: " + result);
275348
}
276349

277-
if (resp.baton() != null) {
278-
this.baton = resp.baton();
350+
if (!rebegin) {
351+
updateStreamState(resp);
352+
closeStreamSilently();
353+
return;
354+
}
355+
356+
if (resp.results().size() < 2) {
357+
closeStreamSilently();
358+
throw new SQLException("Missing response for transaction begin");
279359
}
280-
closeStreamSilently();
360+
StreamResult begin = resp.results().get(1);
361+
if (begin instanceof StreamResult.Error error) {
362+
if (resp.baton() != null) {
363+
this.baton = resp.baton();
364+
}
365+
closeStreamSilently();
366+
throw new SQLException(error.error().message());
367+
}
368+
if (!(begin instanceof StreamResult.Ok beginOk)
369+
|| !(beginOk.response() instanceof StreamResponse.Execute)) {
370+
if (resp.baton() != null) {
371+
this.baton = resp.baton();
372+
}
373+
closeStreamSilently();
374+
throw new SQLException("Unexpected response: " + begin);
375+
}
376+
updateStreamState(resp);
281377
}
282378

283379
@Override
@@ -319,6 +415,27 @@ public String getCatalog() throws SQLException {
319415
return null;
320416
}
321417

418+
/**
419+
* Selects the mode used by the next transaction begin. Matches xerial sqlite-jdbc's {@code
420+
* SQLiteConfig.setTransactionMode}; the default is {@link TransactionMode#DEFERRED}.
421+
*/
422+
public void setTransactionMode(TransactionMode mode) throws SQLException {
423+
checkClosed();
424+
if (mode == null) {
425+
throw new IllegalArgumentException("Transaction mode cannot be null");
426+
}
427+
this.transactionMode = mode;
428+
}
429+
430+
public void setTransactionMode(String mode) throws SQLException {
431+
setTransactionMode(TransactionMode.from(mode));
432+
}
433+
434+
public TransactionMode getTransactionMode() throws SQLException {
435+
checkClosed();
436+
return transactionMode;
437+
}
438+
322439
@Override
323440
public void setTransactionIsolation(int level) throws SQLException {
324441
checkClosed();

0 commit comments

Comments
 (0)