Skip to content

fix(jdbc): serialize LibsqlConnection state under one lock - #153

Merged
magnusp merged 1 commit into
mainfrom
fix/i105-connection-lock
Oct 23, 2026
Merged

magnusp merged 1 commit into
mainfrom
fix/i105-connection-lock

Conversation

@magnusp

@magnusp magnusp commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Fixes #105

Contract. Hrana 3 is the source of truth for transport, values, metadata, batching, transactions, and error reporting: a baton is consumed once and one request may be in flight per stream. java.sql is the source of truth for the JDBC surface. xerial/sqlite-jdbc 3.53.4.0 is the behavioral reference, and this PR mirrors its serialization on the DB object, not its code.

Change

LibsqlConnection now has one stateLock:

  • every pipeline round trip: executeStatement, executeBatchResults, and the private begin/end/close-stream paths that call sendPipeline;
  • every transaction transition: setAutoCommit, commit, rollback, close;
  • the plain state that previously had no synchronization: closed, autoCommit, readOnly, transactionIsolation, the cached metadata, and baton/baseUrl.

Lock ordering

The only two locks are the statement's stateLock and the connection's stateLock. The statement never holds its lock while calling into the connection: LibsqlStatement.checkClosed() releases its lock before calling LibsqlConnection.checkClosed(), and both execute paths call the connection after beginExecution()'s critical section exits. The connection never calls back into a statement. The only possible order is statement lock then connection lock, so it cannot invert and the two cannot deadlock. This is documented in the LibsqlConnection class javadoc and in beginPending, beginTransaction, endTransaction, updateStreamState, and closeStreamSilently.

Red and green

Red at trunk a79ed7d with the new test file only (3/6 fail):

LibsqlStatementConcurrencyTest.closeDoesNotInterleaveWithExecuteAndLeavesNoStreamOpen
  Expecting empty but was: ["concurrent round trip while 1 was in flight: null",
                             "baton reused while in flight: baton-1"]
LibsqlStatementConcurrencyTest.commitDoesNotInterleaveWithTheLazyTransactionBegin
  Expecting empty but was: ["concurrent round trip while 1 was in flight: COMMIT",
                             "baton reused while in flight: baton-1", ...]
LibsqlStatementConcurrencyTest.twoThreadsOnOneNonAutoCommitConnectionDoNotReuseABaton
  Expecting empty but was: ["concurrent round trip while 1 was in flight: BEGIN", ...]

Green at head 17aa994: 6/6 pass, and green on 5 consecutive runs.

The tests use a ProtocolClient that models the protocol rules (one open stream, one request per stream, a baton consumed by its request) and records every violation instead of throwing, so each failure names the invalid interleaving. It widens the two windows with a 50 ms in-flight pause; the fixed connection blocks the racing thread on its lock instead.

Lanes (all at head 17aa994)

Lane Command Result Log
1 ./mvnw -pl libsql-jdbc -am test -Dtest=LibsqlStatementConcurrencyTest at trunk and head trunk 3 failures, head 6/6 /tmp/xerial-parity-i105/lane-1-regression.log
2 commit-versus-ensureTransactionStarted case, 10 runs 10/10 pass /tmp/xerial-parity-i105/lane-2-specific.log
3 close-versus-execute case, 10 runs 10/10 pass, no stream left open /tmp/xerial-parity-i105/lane-3-specific.log
4 ./mvnw -pl libsql-jdbc test 307 tests, BUILD SUCCESS /tmp/xerial-parity-i105/lane-4-module-suite.log
5 ./mvnw -pl libsql-conformance -am verify 123 tests, BUILD SUCCESS, no new divergence /tmp/xerial-parity-i105/lane-5-conformance.log
6 ./mvnw -pl libsql-conformance -am -Pxerial-control verify 100 tests, BUILD SUCCESS /tmp/xerial-parity-i105/lane-6-control.log
7 ./mvnw spotless:check clean /tmp/xerial-parity-i105/lane-7-spotless.log
8 DOCKER_CONFIG=$PWD/libsql-conformance/src/test/docker-config ./mvnw -pl libsql-itests -am verify 28 tests, BUILD SUCCESS /tmp/xerial-parity-i105/lane-8-itests.log
9 ./mvnw -pl libsql-conformance -am -Dit.test=PreparedStatementThreadTest verify 2/2 pass /tmp/xerial-parity-i105/lane-9-specific.log
10 ./mvnw -pl libsql-jdbc -am package -DskipTests jar built /tmp/xerial-parity-i105/lane-10-package.log

Perf

Metric: the two-thread interleaved execute-plus-commit workload, three runs per side interleaved, plus the single-thread libsql-jdbc suite once per side.

  • Workload, trunk / head, three runs each: 126 / 128, 126 / 126, 126 / 126 ms. Medians 126 / 126, 0% change.
  • Suite wall clock: trunk 3.67 s, head 4.01 s (+9.3%, inside the rule). The head suite includes the three new cases and their 200 ms begin-race window; the other 301 tests are unchanged.
  • Probe: /tmp/xerial-parity-i105/PerfProbe.java, run from the built classes of each side. Its client parks 200 µs per round trip, the order of a localhost HTTP+JSON round trip, so the number isolates lock overhead from serialization the protocol requires anyway. Log: /tmp/xerial-parity-i105/perf-probe.log.

Divergences

  • None from xerial behavior. xerial serializes on DB; this serializes on one connection lock, which is the same contract.
  • The perf probe is an in-process fake client with a modeled 200 µs round trip, not a live sqld. A zero-latency fake would charge the fix for the serialization Hrana mandates, so the probe models a round trip instead. Reason: keep the measurement about lock overhead.

Base a79ed7d, head 17aa99459dd2a2be51ec6aa92e5e04a80418dd6a.

@magnusp
magnusp merged commit fdf3a0c into main Oct 23, 2026
6 checks passed
@magnusp
magnusp deleted the fix/i105-connection-lock branch October 23, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LibsqlConnection state is not synchronized

1 participant