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 06467e6..879d6b1 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 @@ -40,11 +40,31 @@ import java.util.Properties; import java.util.concurrent.Executor; +/** + * A JDBC connection whose state is serialized on one connection-level lock, the way sqlite-jdbc + * serializes every call on its {@code DB} object. + * + *
Hrana over HTTP requires serialized requests per stream and consumes a baton once, so each + * pipeline round trip and each transaction transition ({@link #setAutoCommit(boolean)}, {@link + * #commit()}, {@link #rollback()}, {@link #close()}) runs while holding {@link #stateLock}. + * Without it, two statements on one non-auto-commit connection can read the same baton, a commit + * can land between another thread's begin and its send, and a close can leave a stream open. + * + *
Lock ordering. A statement's {@code stateLock} is never held across a call
+ * into the connection, so the only possible order is statement lock, then connection lock. {@code
+ * LibsqlStatement.checkClosed()} releases its statement lock before calling {@link
+ * #checkClosed()}, and both execute paths call {@link #executeStatement(Stmt)} and {@link
+ * #executeBatchResults(List)} after their statement critical sections have exited. The connection
+ * never calls back into a statement, so the order cannot invert and the two locks cannot deadlock.
+ */
public class LibsqlConnection implements Connection {
private final LibsqlClientConfig config;
private final LibsqlHttpClient client;
+ /** Guards every field below and every pipeline round trip. See the class javadoc. */
+ private final Object stateLock = new Object();
+
private boolean autoCommit = true;
private boolean readOnly = false;
private int transactionIsolation = TRANSACTION_SERIALIZABLE;
@@ -94,39 +114,43 @@ public LibsqlClientConfig getConfig() {
}
public void checkClosed() throws SQLException {
- if (closed) {
- throw new SQLException("Connection is closed");
+ synchronized (stateLock) {
+ if (closed) {
+ throw new SQLException("Connection is closed");
+ }
}
}
public StmtResult executeStatement(Stmt stmt) throws SQLException {
- checkClosed();
- try {
- if (autoCommit) {
- // One-shot execution with single-roundtrip close (prevents Bug 1 128-stream 10s stall)
- return client.executeOneShot(stmt);
- } else {
- ensureTransactionStarted();
- PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt)));
- PipelineRespBody resp = client.sendPipeline(req, baseUrl);
- updateStreamState(resp);
-
- if (resp.results().isEmpty()) {
- throw new SQLException("Empty result received from server");
- }
- StreamResult sr = resp.results().get(0);
- return switch (sr) {
- case StreamResult.Ok ok -> {
- if (ok.response() instanceof StreamResponse.Execute exec) {
- yield exec.result();
- }
- throw new SQLException("Unexpected response: " + ok.response());
+ synchronized (stateLock) {
+ checkClosed();
+ try {
+ if (autoCommit) {
+ // One-shot execution with single-roundtrip close (prevents Bug 1 128-stream 10s stall)
+ return client.executeOneShot(stmt);
+ } else {
+ ensureTransactionStarted();
+ PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt)));
+ PipelineRespBody resp = client.sendPipeline(req, baseUrl);
+ updateStreamState(resp);
+
+ if (resp.results().isEmpty()) {
+ throw new SQLException("Empty result received from server");
}
- case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error());
- };
+ StreamResult sr = resp.results().get(0);
+ return switch (sr) {
+ case StreamResult.Ok ok -> {
+ if (ok.response() instanceof StreamResponse.Execute exec) {
+ yield exec.result();
+ }
+ throw new SQLException("Unexpected response: " + ok.response());
+ }
+ case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error());
+ };
+ }
+ } catch (LibsqlException e) {
+ throw LibsqlErrorMapper.toSQLException(e);
}
- } catch (LibsqlException e) {
- throw LibsqlErrorMapper.toSQLException(e);
}
}
@@ -145,36 +169,38 @@ public int[] executeBatch(List Callers hold {@link #stateLock}.
*/
private boolean beginPending() {
return !autoCommit && baton == null;
}
+ /** Callers hold {@link #stateLock}. */
private void beginTransaction() throws SQLException {
PipelineRespBody resp;
try {
@@ -267,6 +296,7 @@ private void beginTransaction() throws SQLException {
}
}
+ /** Callers hold {@link #stateLock}. */
private void updateStreamState(PipelineRespBody resp) {
this.baton = resp.baton();
if (resp.baseUrl() != null && !resp.baseUrl().isBlank()) {
@@ -274,6 +304,7 @@ private void updateStreamState(PipelineRespBody resp) {
}
}
+ /** Callers hold {@link #stateLock}. */
private void closeStreamSilently() {
if (baton != null) {
try {
@@ -334,40 +365,49 @@ public String nativeSQL(String sql) throws SQLException {
*/
@Override
public void setAutoCommit(boolean autoCommit) throws SQLException {
- checkClosed();
- if (this.autoCommit == autoCommit) return;
- if (autoCommit) {
- endTransaction("COMMIT");
- this.autoCommit = true;
- } else {
- this.autoCommit = false;
+ synchronized (stateLock) {
+ checkClosed();
+ if (this.autoCommit == autoCommit) return;
+ if (autoCommit) {
+ endTransaction("COMMIT");
+ this.autoCommit = true;
+ } else {
+ this.autoCommit = false;
+ }
}
}
@Override
public boolean getAutoCommit() throws SQLException {
- checkClosed();
- return autoCommit;
+ synchronized (stateLock) {
+ checkClosed();
+ return autoCommit;
+ }
}
@Override
public void commit() throws SQLException {
- checkClosed();
- if (autoCommit) {
- throw new SQLException("Cannot commit when in autoCommit mode");
+ synchronized (stateLock) {
+ checkClosed();
+ if (autoCommit) {
+ throw new SQLException("Cannot commit when in autoCommit mode");
+ }
+ endTransaction("COMMIT");
}
- endTransaction("COMMIT");
}
@Override
public void rollback() throws SQLException {
- checkClosed();
- if (autoCommit) {
- throw new SQLException("Cannot rollback when in autoCommit mode");
+ synchronized (stateLock) {
+ checkClosed();
+ if (autoCommit) {
+ throw new SQLException("Cannot rollback when in autoCommit mode");
+ }
+ endTransaction("ROLLBACK");
}
- endTransaction("ROLLBACK");
}
+ /** Callers hold {@link #stateLock}. */
private void endTransaction(String sql) throws SQLException {
if (beginPending()) {
// No transaction has started, so there is nothing to send and nothing to close.
@@ -404,15 +444,19 @@ private void endTransaction(String sql) throws SQLException {
@Override
public void close() throws SQLException {
- if (!closed) {
- closed = true;
- closeStreamSilently();
+ synchronized (stateLock) {
+ if (!closed) {
+ closed = true;
+ closeStreamSilently();
+ }
}
}
@Override
public boolean isClosed() throws SQLException {
- return closed;
+ synchronized (stateLock) {
+ return closed;
+ }
}
/**
@@ -423,23 +467,29 @@ public boolean isClosed() throws SQLException {
*/
@Override
public DatabaseMetaData getMetaData() throws SQLException {
- checkClosed();
- if (meta == null) {
- meta = new LibsqlDatabaseMetaData(this);
+ synchronized (stateLock) {
+ checkClosed();
+ if (meta == null) {
+ meta = new LibsqlDatabaseMetaData(this);
+ }
+ return meta;
}
- return meta;
}
@Override
public void setReadOnly(boolean readOnly) throws SQLException {
- checkClosed();
- this.readOnly = readOnly;
+ synchronized (stateLock) {
+ checkClosed();
+ this.readOnly = readOnly;
+ }
}
@Override
public boolean isReadOnly() throws SQLException {
- checkClosed();
- return readOnly;
+ synchronized (stateLock) {
+ checkClosed();
+ return readOnly;
+ }
}
@Override
@@ -455,11 +505,13 @@ public String getCatalog() throws SQLException {
* SQLiteConfig.setTransactionMode}; the default is {@link TransactionMode#DEFERRED}.
*/
public void setTransactionMode(TransactionMode mode) throws SQLException {
- checkClosed();
- if (mode == null) {
- throw new IllegalArgumentException("Transaction mode cannot be null");
+ synchronized (stateLock) {
+ checkClosed();
+ if (mode == null) {
+ throw new IllegalArgumentException("Transaction mode cannot be null");
+ }
+ this.transactionMode = mode;
}
- this.transactionMode = mode;
}
public void setTransactionMode(String mode) throws SQLException {
@@ -467,20 +519,26 @@ public void setTransactionMode(String mode) throws SQLException {
}
public TransactionMode getTransactionMode() throws SQLException {
- checkClosed();
- return transactionMode;
+ synchronized (stateLock) {
+ checkClosed();
+ return transactionMode;
+ }
}
@Override
public void setTransactionIsolation(int level) throws SQLException {
- checkClosed();
- this.transactionIsolation = level;
+ synchronized (stateLock) {
+ checkClosed();
+ this.transactionIsolation = level;
+ }
}
@Override
public int getTransactionIsolation() throws SQLException {
- checkClosed();
- return transactionIsolation;
+ synchronized (stateLock) {
+ checkClosed();
+ return transactionIsolation;
+ }
}
@Override
@@ -601,7 +659,9 @@ public PreparedStatement prepareStatement(String sql, String[] columnNames) thro
@Override
public boolean isValid(int timeout) throws SQLException {
- if (closed) return false;
+ synchronized (stateLock) {
+ if (closed) return false;
+ }
try {
executeStatement(new Stmt("SELECT 1", null, List.of(), List.of(), false));
return true;
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java
index bf315cc..08dc22b 100644
--- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java
@@ -1,20 +1,34 @@
package com.github.magnusp.libsql.jdbc;
+import com.github.magnusp.libsql.client.LibsqlClientConfig;
+import com.github.magnusp.libsql.client.LibsqlHttpClient;
import com.github.magnusp.libsql.hrana.codec.Col;
+import com.github.magnusp.libsql.hrana.codec.PipelineReqBody;
+import com.github.magnusp.libsql.hrana.codec.PipelineRespBody;
import com.github.magnusp.libsql.hrana.codec.Row;
import com.github.magnusp.libsql.hrana.codec.Stmt;
import com.github.magnusp.libsql.hrana.codec.StmtResult;
+import com.github.magnusp.libsql.hrana.codec.StreamRequest;
+import com.github.magnusp.libsql.hrana.codec.StreamResponse;
+import com.github.magnusp.libsql.hrana.codec.StreamResult;
import com.github.magnusp.libsql.hrana.codec.Value;
import org.junit.jupiter.api.Test;
+import java.net.URI;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
@@ -26,6 +40,8 @@ class LibsqlStatementConcurrencyTest {
List.of(new Row(List.of(Value.of(1L)))),
0,
null);
+ private static final LibsqlClientConfig CONFIG =
+ new LibsqlClientConfig("http://localhost:1", null);
@Test
void plainExecuteQueryDoesNotAttachAnOpenResultSetWhenCloseWinsTheRace() throws Exception {
@@ -85,6 +101,129 @@ void executeQueryOnAClosedStatementReturnsAClosedResultSet() throws SQLException
assertThat(resultSet.isClosed()).isTrue();
}
+ /**
+ * Two threads share one non-auto-commit connection. Hrana consumes a baton once and allows one
+ * request per stream at a time, so the two lazily started transactions must not both send
+ * {@code BEGIN} from the same empty stream state.
+ */
+ @Test
+ void twoThreadsOnOneNonAutoCommitConnectionDoNotReuseABaton() throws Exception {
+ ProtocolClient client = new ProtocolClient(new CyclicBarrier(2));
+ LibsqlConnection connection = new LibsqlConnection(CONFIG, client);
+ connection.setAutoCommit(false);
+ LibsqlStatement first = new LibsqlStatement(connection);
+ LibsqlStatement second = new LibsqlStatement(connection);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ Future> firstWork = executor.submit(() -> executeRepeatedly(first, 20));
+ Future> secondWork = executor.submit(() -> executeRepeatedly(second, 20));
+
+ assertThat(failureOf(firstWork, secondWork)).isNull();
+ assertThat(client.violations()).as("protocol violations").isEmpty();
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * A commit from another thread must not land between the lazy transaction begin and the
+ * statement sent on the new baton.
+ */
+ @Test
+ void commitDoesNotInterleaveWithTheLazyTransactionBegin() throws Exception {
+ ProtocolClient client = new ProtocolClient(null);
+ LibsqlConnection connection = new LibsqlConnection(CONFIG, client);
+ connection.setAutoCommit(false);
+ LibsqlStatement statement = new LibsqlStatement(connection);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future> work = executor.submit(() -> statement.execute("SELECT 1"));
+ assertThat(client.awaitStatementInFlight()).isTrue();
+
+ AtomicReference