4040import java .util .Properties ;
4141import 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+ */
4360public 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