Skip to content

Commit 17aa994

Browse files
committed
fix(jdbc): serialize connection state under one lock
1 parent a79ed7d commit 17aa994

2 files changed

Lines changed: 431 additions & 94 deletions

File tree

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

Lines changed: 154 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,31 @@
4040
import java.util.Properties;
4141
import java.util.concurrent.Executor;
4242

43+
/**
44+
* A JDBC connection whose state is serialized on one connection-level lock, the way sqlite-jdbc
45+
* serializes every call on its {@code DB} object.
46+
*
47+
* <p>Hrana over HTTP requires serialized requests per stream and consumes a baton once, so each
48+
* pipeline round trip and each transaction transition ({@link #setAutoCommit(boolean)}, {@link
49+
* #commit()}, {@link #rollback()}, {@link #close()}) runs while holding {@link #stateLock}.
50+
* Without it, two statements on one non-auto-commit connection can read the same baton, a commit
51+
* can land between another thread's begin and its send, and a close can leave a stream open.
52+
*
53+
* <p><strong>Lock ordering.</strong> A statement's {@code stateLock} is never held across a call
54+
* into the connection, so the only possible order is statement lock, then connection lock. {@code
55+
* LibsqlStatement.checkClosed()} releases its statement lock before calling {@link
56+
* #checkClosed()}, and both execute paths call {@link #executeStatement(Stmt)} and {@link
57+
* #executeBatchResults(List)} after their statement critical sections have exited. The connection
58+
* never calls back into a statement, so the order cannot invert and the two locks cannot deadlock.
59+
*/
4360
public class LibsqlConnection implements Connection {
4461

4562
private final LibsqlClientConfig config;
4663
private final LibsqlHttpClient client;
4764

65+
/** Guards every field below and every pipeline round trip. See the class javadoc. */
66+
private final Object stateLock = new Object();
67+
4868
private boolean autoCommit = true;
4969
private boolean readOnly = false;
5070
private int transactionIsolation = TRANSACTION_SERIALIZABLE;
@@ -94,39 +114,43 @@ public LibsqlClientConfig getConfig() {
94114
}
95115

96116
public void checkClosed() throws SQLException {
97-
if (closed) {
98-
throw new SQLException("Connection is closed");
117+
synchronized (stateLock) {
118+
if (closed) {
119+
throw new SQLException("Connection is closed");
120+
}
99121
}
100122
}
101123

102124
public StmtResult executeStatement(Stmt stmt) throws SQLException {
103-
checkClosed();
104-
try {
105-
if (autoCommit) {
106-
// One-shot execution with single-roundtrip close (prevents Bug 1 128-stream 10s stall)
107-
return client.executeOneShot(stmt);
108-
} else {
109-
ensureTransactionStarted();
110-
PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt)));
111-
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
112-
updateStreamState(resp);
113-
114-
if (resp.results().isEmpty()) {
115-
throw new SQLException("Empty result received from server");
116-
}
117-
StreamResult sr = resp.results().get(0);
118-
return switch (sr) {
119-
case StreamResult.Ok ok -> {
120-
if (ok.response() instanceof StreamResponse.Execute exec) {
121-
yield exec.result();
122-
}
123-
throw new SQLException("Unexpected response: " + ok.response());
125+
synchronized (stateLock) {
126+
checkClosed();
127+
try {
128+
if (autoCommit) {
129+
// One-shot execution with single-roundtrip close (prevents Bug 1 128-stream 10s stall)
130+
return client.executeOneShot(stmt);
131+
} else {
132+
ensureTransactionStarted();
133+
PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt)));
134+
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
135+
updateStreamState(resp);
136+
137+
if (resp.results().isEmpty()) {
138+
throw new SQLException("Empty result received from server");
124139
}
125-
case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error());
126-
};
140+
StreamResult sr = resp.results().get(0);
141+
return switch (sr) {
142+
case StreamResult.Ok ok -> {
143+
if (ok.response() instanceof StreamResponse.Execute exec) {
144+
yield exec.result();
145+
}
146+
throw new SQLException("Unexpected response: " + ok.response());
147+
}
148+
case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error());
149+
};
150+
}
151+
} catch (LibsqlException e) {
152+
throw LibsqlErrorMapper.toSQLException(e);
127153
}
128-
} catch (LibsqlException e) {
129-
throw LibsqlErrorMapper.toSQLException(e);
130154
}
131155
}
132156

@@ -145,36 +169,38 @@ public int[] executeBatch(List<Stmt> statements) throws SQLException {
145169
* driver needs no follow-up query and no extra round trip.
146170
*/
147171
List<StmtResult> executeBatchResults(List<Stmt> statements) throws SQLException {
148-
checkClosed();
149-
if (statements == null || statements.isEmpty()) {
150-
return List.of();
151-
}
152-
153-
try {
154-
List<BatchStep> steps = new ArrayList<>(statements.size());
155-
for (int i = 0; i < statements.size(); i++) {
156-
BatchCond condition = i == 0 ? null : new BatchCond.StepOk(i - 1);
157-
steps.add(new BatchStep(condition, statements.get(i)));
172+
synchronized (stateLock) {
173+
checkClosed();
174+
if (statements == null || statements.isEmpty()) {
175+
return List.of();
158176
}
159-
Batch batch = new Batch(steps);
160177

161-
if (autoCommit) {
162-
// One-shot batch
163-
PipelineReqBody req = new PipelineReqBody(null, List.of(
164-
new StreamRequest.BatchReq(batch),
165-
StreamRequest.Close.INSTANCE
166-
));
167-
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
168-
return extractBatchResults(resp, statements.size());
169-
} else {
170-
ensureTransactionStarted();
171-
PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.BatchReq(batch)));
172-
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
173-
updateStreamState(resp);
174-
return extractBatchResults(resp, statements.size());
178+
try {
179+
List<BatchStep> steps = new ArrayList<>(statements.size());
180+
for (int i = 0; i < statements.size(); i++) {
181+
BatchCond condition = i == 0 ? null : new BatchCond.StepOk(i - 1);
182+
steps.add(new BatchStep(condition, statements.get(i)));
183+
}
184+
Batch batch = new Batch(steps);
185+
186+
if (autoCommit) {
187+
// One-shot batch
188+
PipelineReqBody req = new PipelineReqBody(null, List.of(
189+
new StreamRequest.BatchReq(batch),
190+
StreamRequest.Close.INSTANCE
191+
));
192+
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
193+
return extractBatchResults(resp, statements.size());
194+
} else {
195+
ensureTransactionStarted();
196+
PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.BatchReq(batch)));
197+
PipelineRespBody resp = client.sendPipeline(req, baseUrl);
198+
updateStreamState(resp);
199+
return extractBatchResults(resp, statements.size());
200+
}
201+
} catch (LibsqlException e) {
202+
throw LibsqlErrorMapper.toSQLException(e);
175203
}
176-
} catch (LibsqlException e) {
177-
throw LibsqlErrorMapper.toSQLException(e);
178204
}
179205
}
180206

@@ -236,11 +262,14 @@ private void ensureTransactionStarted() throws SQLException {
236262
* The begin-pending state is derived rather than stored: auto-commit is off and no stream is
237263
* open, so the next statement must begin the transaction. A separate boolean would be a second
238264
* source of truth for a fact the baton already carries.
265+
*
266+
* <p>Callers hold {@link #stateLock}.
239267
*/
240268
private boolean beginPending() {
241269
return !autoCommit && baton == null;
242270
}
243271

272+
/** Callers hold {@link #stateLock}. */
244273
private void beginTransaction() throws SQLException {
245274
PipelineRespBody resp;
246275
try {
@@ -267,13 +296,15 @@ private void beginTransaction() throws SQLException {
267296
}
268297
}
269298

299+
/** Callers hold {@link #stateLock}. */
270300
private void updateStreamState(PipelineRespBody resp) {
271301
this.baton = resp.baton();
272302
if (resp.baseUrl() != null && !resp.baseUrl().isBlank()) {
273303
this.baseUrl = URI.create(resp.baseUrl());
274304
}
275305
}
276306

307+
/** Callers hold {@link #stateLock}. */
277308
private void closeStreamSilently() {
278309
if (baton != null) {
279310
try {
@@ -334,40 +365,49 @@ public String nativeSQL(String sql) throws SQLException {
334365
*/
335366
@Override
336367
public void setAutoCommit(boolean autoCommit) throws SQLException {
337-
checkClosed();
338-
if (this.autoCommit == autoCommit) return;
339-
if (autoCommit) {
340-
endTransaction("COMMIT");
341-
this.autoCommit = true;
342-
} else {
343-
this.autoCommit = false;
368+
synchronized (stateLock) {
369+
checkClosed();
370+
if (this.autoCommit == autoCommit) return;
371+
if (autoCommit) {
372+
endTransaction("COMMIT");
373+
this.autoCommit = true;
374+
} else {
375+
this.autoCommit = false;
376+
}
344377
}
345378
}
346379

347380
@Override
348381
public boolean getAutoCommit() throws SQLException {
349-
checkClosed();
350-
return autoCommit;
382+
synchronized (stateLock) {
383+
checkClosed();
384+
return autoCommit;
385+
}
351386
}
352387

353388
@Override
354389
public void commit() throws SQLException {
355-
checkClosed();
356-
if (autoCommit) {
357-
throw new SQLException("Cannot commit when in autoCommit mode");
390+
synchronized (stateLock) {
391+
checkClosed();
392+
if (autoCommit) {
393+
throw new SQLException("Cannot commit when in autoCommit mode");
394+
}
395+
endTransaction("COMMIT");
358396
}
359-
endTransaction("COMMIT");
360397
}
361398

362399
@Override
363400
public void rollback() throws SQLException {
364-
checkClosed();
365-
if (autoCommit) {
366-
throw new SQLException("Cannot rollback when in autoCommit mode");
401+
synchronized (stateLock) {
402+
checkClosed();
403+
if (autoCommit) {
404+
throw new SQLException("Cannot rollback when in autoCommit mode");
405+
}
406+
endTransaction("ROLLBACK");
367407
}
368-
endTransaction("ROLLBACK");
369408
}
370409

410+
/** Callers hold {@link #stateLock}. */
371411
private void endTransaction(String sql) throws SQLException {
372412
if (beginPending()) {
373413
// 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 {
404444

405445
@Override
406446
public void close() throws SQLException {
407-
if (!closed) {
408-
closed = true;
409-
closeStreamSilently();
447+
synchronized (stateLock) {
448+
if (!closed) {
449+
closed = true;
450+
closeStreamSilently();
451+
}
410452
}
411453
}
412454

413455
@Override
414456
public boolean isClosed() throws SQLException {
415-
return closed;
457+
synchronized (stateLock) {
458+
return closed;
459+
}
416460
}
417461

418462
/**
@@ -423,23 +467,29 @@ public boolean isClosed() throws SQLException {
423467
*/
424468
@Override
425469
public DatabaseMetaData getMetaData() throws SQLException {
426-
checkClosed();
427-
if (meta == null) {
428-
meta = new LibsqlDatabaseMetaData(this);
470+
synchronized (stateLock) {
471+
checkClosed();
472+
if (meta == null) {
473+
meta = new LibsqlDatabaseMetaData(this);
474+
}
475+
return meta;
429476
}
430-
return meta;
431477
}
432478

433479
@Override
434480
public void setReadOnly(boolean readOnly) throws SQLException {
435-
checkClosed();
436-
this.readOnly = readOnly;
481+
synchronized (stateLock) {
482+
checkClosed();
483+
this.readOnly = readOnly;
484+
}
437485
}
438486

439487
@Override
440488
public boolean isReadOnly() throws SQLException {
441-
checkClosed();
442-
return readOnly;
489+
synchronized (stateLock) {
490+
checkClosed();
491+
return readOnly;
492+
}
443493
}
444494

445495
@Override
@@ -455,32 +505,40 @@ public String getCatalog() throws SQLException {
455505
* SQLiteConfig.setTransactionMode}; the default is {@link TransactionMode#DEFERRED}.
456506
*/
457507
public void setTransactionMode(TransactionMode mode) throws SQLException {
458-
checkClosed();
459-
if (mode == null) {
460-
throw new IllegalArgumentException("Transaction mode cannot be null");
508+
synchronized (stateLock) {
509+
checkClosed();
510+
if (mode == null) {
511+
throw new IllegalArgumentException("Transaction mode cannot be null");
512+
}
513+
this.transactionMode = mode;
461514
}
462-
this.transactionMode = mode;
463515
}
464516

465517
public void setTransactionMode(String mode) throws SQLException {
466518
setTransactionMode(TransactionMode.from(mode));
467519
}
468520

469521
public TransactionMode getTransactionMode() throws SQLException {
470-
checkClosed();
471-
return transactionMode;
522+
synchronized (stateLock) {
523+
checkClosed();
524+
return transactionMode;
525+
}
472526
}
473527

474528
@Override
475529
public void setTransactionIsolation(int level) throws SQLException {
476-
checkClosed();
477-
this.transactionIsolation = level;
530+
synchronized (stateLock) {
531+
checkClosed();
532+
this.transactionIsolation = level;
533+
}
478534
}
479535

480536
@Override
481537
public int getTransactionIsolation() throws SQLException {
482-
checkClosed();
483-
return transactionIsolation;
538+
synchronized (stateLock) {
539+
checkClosed();
540+
return transactionIsolation;
541+
}
484542
}
485543

486544
@Override
@@ -601,7 +659,9 @@ public PreparedStatement prepareStatement(String sql, String[] columnNames) thro
601659

602660
@Override
603661
public boolean isValid(int timeout) throws SQLException {
604-
if (closed) return false;
662+
synchronized (stateLock) {
663+
if (closed) return false;
664+
}
605665
try {
606666
executeStatement(new Stmt("SELECT 1", null, List.of(), List.of(), false));
607667
return true;

0 commit comments

Comments
 (0)