Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@

public class TransactionLifecycleIT extends SqldContainerBase {

/** sqld expires an idle Hrana stream after ten seconds. */
private static final long STREAM_IDLE_TIMEOUT_MILLIS = 12_000;

private String getDriverJdbcUrl() {
return "jdbc:libsql:" + getHttpUrl();
}

@Test
void deferredTransactionBeginsEagerlyWithoutHoldingAWriteLock() throws Exception {
void deferredTransactionDoesNotHoldAWriteLock() throws Exception {
String url = getDriverJdbcUrl();
try (Connection lockHolder = DriverManager.getConnection(url);
Connection writer = DriverManager.getConnection(url)) {
Expand All @@ -43,21 +46,23 @@ void deferredTransactionBeginsEagerlyWithoutHoldingAWriteLock() throws Exception
}

@Test
void immediateTransactionBeginsEagerlyAndHoldsTheWriteLock() throws Exception {
void immediateTransactionHoldsTheWriteLockFromTheFirstStatement() throws Exception {
String url = getDriverJdbcUrl();
try (Connection lockHolder = DriverManager.getConnection(url)) {
try (Connection lockHolder = DriverManager.getConnection(url);
Statement statement = lockHolder.createStatement()) {
createTable(lockHolder, "immediate_lock");
LibsqlConnection libsql = lockHolder.unwrap(LibsqlConnection.class);
libsql.setTransactionMode(LibsqlConnection.TransactionMode.IMMEDIATE);

lockHolder.setAutoCommit(false);
statement.executeUpdate("INSERT INTO immediate_lock (id) VALUES (100)");

assertWriteIsBlockedUntilReleased(url, lockHolder, "immediate_lock", 1);
}
}

@Test
void commitBeginsTheNextTransactionAndKeepsTheConfiguredLock() throws Exception {
void commitEndsTheTransactionAndTheNextStatementReacquiresTheConfiguredLock() throws Exception {
String url = getDriverJdbcUrl();
try (Connection lockHolder = DriverManager.getConnection(url);
Statement statement = lockHolder.createStatement()) {
Expand All @@ -69,10 +74,68 @@ void commitBeginsTheNextTransactionAndKeepsTheConfiguredLock() throws Exception

lockHolder.commit();

// The next transaction, and its IMMEDIATE lock, begins on the next statement.
statement.executeUpdate("INSERT INTO commit_rebegin (id) VALUES (101)");

assertWriteIsBlockedUntilReleased(url, lockHolder, "commit_rebegin", 1);
}
}

@Test
void aStatementAndCommitAfterAnIdlePeriodLongerThanTheStreamTimeoutSucceed() throws Exception {
String url = getDriverJdbcUrl();
try (Connection connection = DriverManager.getConnection(url)) {
createTable(connection, "idle_pending_begin");

connection.setAutoCommit(false);
connection.commit();
Thread.sleep(STREAM_IDLE_TIMEOUT_MILLIS);

try (Statement statement = connection.createStatement()) {
assertThat(statement.executeUpdate("INSERT INTO idle_pending_begin (id) VALUES (1)"))
.isEqualTo(1);
}
connection.commit();

try (Statement statement = connection.createStatement();
ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM idle_pending_begin")) {
assertThat(rows.next()).isTrue();
assertThat(rows.getInt(1)).isEqualTo(1);
}
}
}

@Test
void anIdleConnectionHoldsNoStreamAfterCommit() throws Exception {
String url = getDriverJdbcUrl();
try (Connection connection = DriverManager.getConnection(url)) {
createTable(connection, "idle_after_commit");

connection.setAutoCommit(false);
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("INSERT INTO idle_after_commit (id) VALUES (1)");
}
connection.commit();

Thread.sleep(STREAM_IDLE_TIMEOUT_MILLIS);

try (Statement statement = connection.createStatement()) {
assertThat(statement.executeUpdate("INSERT INTO idle_after_commit (id) VALUES (2)"))
.isEqualTo(1);
assertThat(statement.executeUpdate("INSERT INTO idle_after_commit (id) VALUES (3)"))
.isEqualTo(1);
}
connection.rollback();
connection.commit();

try (Statement statement = connection.createStatement();
ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM idle_after_commit")) {
assertThat(rows.next()).isTrue();
assertThat(rows.getInt(1)).isEqualTo(1);
}
}
}

@Test
void failedCommitCanBeRolledBackAndTheConnectionStaysUsable() throws Exception {
String url = getDriverJdbcUrl();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,20 @@ private int[] extractBatchResults(PipelineRespBody resp, int expectedSize) throw
}

private void ensureTransactionStarted() throws SQLException {
if (baton == null) {
if (beginPending()) {
beginTransaction();
}
}

/**
* The begin-pending state is derived rather than stored: auto-commit is off and no stream is
* open, so the next statement must begin the transaction. A separate boolean would be a second
* source of truth for a fact the baton already carries.
*/
private boolean beginPending() {
return !autoCommit && baton == null;
}

private void beginTransaction() throws SQLException {
PipelineRespBody resp;
try {
Expand Down Expand Up @@ -273,18 +282,24 @@ public String nativeSQL(String sql) throws SQLException {
}

/**
* Matches xerial sqlite-jdbc 3.53.4.0:
* Matches the observable JDBC behaviour of xerial sqlite-jdbc 3.53.4.0 while keeping the
* transaction begin lazy:
*
* <ul>
* <li>Disabling auto-commit immediately begins a transaction in the configured
* {@link TransactionMode}. The default is {@link TransactionMode#DEFERRED}, so an idle
* non-auto-commit connection holds no lock until its first statement.
* <li>{@link #commit()} and {@link #rollback()} end the current transaction and immediately
* begin the next one, so a non-auto-commit connection always has a transaction open.
* <li>Disabling auto-commit only records that the next statement must begin a transaction in
* the configured {@link TransactionMode}. No request is sent, so an idle non-auto-commit
* connection holds no Hrana stream and cannot hit sqld's idle stream expiry.
* <li>{@link #commit()} and {@link #rollback()} end the open transaction and close its stream
* without beginning the next one. A commit or rollback with no work sent nothing to the
* server, as in xerial.
* <li>Re-enabling auto-commit commits the open transaction and ends the Hrana transaction
* stream.
* </ul>
*
* <p>This diverges from xerial only in when the server sees the begin: an {@code IMMEDIATE} or
* {@code EXCLUSIVE} transaction no longer takes its write lock until the next statement. The
* protocol requires it, because Hrana over HTTP expires idle streams.
*
* <p>Use {@link #setTransactionMode(TransactionMode)} to select {@code IMMEDIATE} or {@code
* EXCLUSIVE}, which acquire the write lock when the transaction begins.
*/
Expand All @@ -293,11 +308,10 @@ public void setAutoCommit(boolean autoCommit) throws SQLException {
checkClosed();
if (this.autoCommit == autoCommit) return;
if (autoCommit) {
endTransaction("COMMIT", false);
endTransaction("COMMIT");
this.autoCommit = true;
} else {
this.autoCommit = false;
beginTransaction();
}
}

Expand All @@ -313,7 +327,7 @@ public void commit() throws SQLException {
if (autoCommit) {
throw new SQLException("Cannot commit when in autoCommit mode");
}
endTransaction("COMMIT", true);
endTransaction("COMMIT");
}

@Override
Expand All @@ -322,26 +336,20 @@ public void rollback() throws SQLException {
if (autoCommit) {
throw new SQLException("Cannot rollback when in autoCommit mode");
}
endTransaction("ROLLBACK", true);
endTransaction("ROLLBACK");
}

private void endTransaction(String sql, boolean rebegin) throws SQLException {
if (baton == null) {
if (rebegin) {
throw new SQLException("No transaction is active");
}
private void endTransaction(String sql) throws SQLException {
if (beginPending()) {
// No transaction has started, so there is nothing to send and nothing to close.
return;
}

List<StreamRequest> requests = new ArrayList<>(2);
requests.add(new StreamRequest.Execute(new Stmt(sql)));
if (rebegin) {
requests.add(new StreamRequest.Execute(new Stmt(transactionMode.beginSql())));
}

PipelineRespBody resp;
try {
resp = client.sendPipeline(new PipelineReqBody(baton, requests), baseUrl);
PipelineReqBody req = new PipelineReqBody(baton,
List.of(new StreamRequest.Execute(new Stmt(sql))));
resp = client.sendPipeline(req, baseUrl);
} catch (LibsqlException e) {
throw LibsqlErrorMapper.toSQLException(e);
}
Expand All @@ -359,25 +367,10 @@ private void endTransaction(String sql, boolean rebegin) throws SQLException {
throw new SQLException("Unexpected response: " + result);
}

if (!rebegin) {
closeStreamSilently();
return;
}

if (resp.results().size() < 2) {
closeStreamSilently();
throw new SQLException("Missing response for transaction begin");
}
StreamResult begin = resp.results().get(1);
if (begin instanceof StreamResult.Error error) {
closeStreamSilently();
throw new SQLException(error.error().message());
}
if (!(begin instanceof StreamResult.Ok beginOk)
|| !(beginOk.response() instanceof StreamResponse.Execute)) {
closeStreamSilently();
throw new SQLException("Unexpected response: " + begin);
}
// The server committed or rolled back, so the stream has no state left worth holding.
// Closing it lets an idle connection drop its server-side stream. On an error above the
// baton stays, so the caller can retry or roll back on the same stream.
closeStreamSilently();
}

@Override
Expand Down
Loading
Loading