Skip to content

feat(jdbc): accept transactionMode in the JDBC URL and connection properties - #295

Merged
magnusp merged 3 commits into
mainfrom
feat/294-transaction-mode
Nov 2, 2026
Merged

magnusp merged 3 commits into
mainfrom
feat/294-transaction-mode

Conversation

@magnusp

@magnusp magnusp commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Why

Issue #294. sqld opens a deferred transaction by default. A transaction that reads before it writes then fails its snapshot upgrade with SQLITE_BUSY_SNAPSHOT when another connection commits. Selecting BEGIN IMMEDIATE from configuration removes that failure without a wrapper that unwraps LibsqlConnection.

The same live sqld experiment that proved a read-only token boundary also exposed a transport bug: a 403 with {"error":"..."} was mapped to HY000 with an empty message.

What changed

  • transactionMode is now a JDBC URL parameter and a connection property: deferred, immediate, or exclusive, case-insensitive, default deferred. A URL value wins over a property; an unknown value fails at connect.
  • The parser produces a package-private ParsedConnection, so the public parse signature and LibsqlClientConfig stay unchanged.
  • HTTP auth refusals keep the server's message and status, map to SQLState 28000, and drop the baton so the connection can read again after a refused write.
  • The README documents the parameters and that read-only needs a server-enforced token, not setReadOnly.

Scope

  • No readOnly JDBC parameter. sqld refuses every PRAGMA query_only form, so a driver-side parser would be a fake boundary. A server-enforced read-only connection uses a sqld JWT with a: ro.
  • No change to LibsqlClientConfig or the wire config; the mode is per-connection behaviour.

Tradeoffs

  • An unknown mode fails at connect instead of falling back to deferred, so a typo cannot silently change locking.
  • The auth fix surfaces the status for every 401/403, not only for the read-only token case.

Blast Radius

  • Existing URLs and connections default to deferred, so behaviour is unchanged unless transactionMode is set.
  • The auth mapping changes only the exception message, type, and SQLState for 401/403 responses; the stream reset keeps the connection usable.

Verification

  • ./mvnw -o -pl libsql-jdbc test: 1636 tests, 0 failures, 0 errors.
  • TransactionModeIT (2) and AuthTokenIT (4) against sqld v0.24.33: immediate holds the write lock from the first statement; a read-only token reads, a write is refused with SQLInvalidAuthorizationSpecException / 28000 and the server message, and the same connection reads again.
  • ./mvnw -o spotless:check: pass.
  • The libsql-client socket unit test cannot bind under the local nono sandbox (BindException: Permission denied); the same path is covered end to end by AuthTokenIT and runs on CI.

…perties

The mode selects the begin SQL for the next transaction, so a write
transaction can take its lock at BEGIN instead of failing a read-to-write
upgrade with SQLITE_BUSY_SNAPSHOT. The value is connection behaviour, not
transport config, so it travels in a ParsedConnection alongside the client
config rather than in LibsqlClientConfig.
sqld answers a forbidden write with HTTP 403 and {"error":"..."}, which
the codecs read as an empty Hrana message. Treating the error field as the
message keeps the server's refusal text, and preserving the HTTP status lets
LibsqlErrorMapper report SQLState 28000 instead of HY000.

The server invalidates the baton with the refusal, so clearing it on 401/403
lets the next statement open a fresh stream rather than replay a dead one.
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Performance benchmark

Base 2769e69feea9 → head 311811818d64. Medians of 3 base runs and 3 head runs, interleaved on the same runner.

Driver-side microbenchmarks only; server and network time are excluded. Shared-runner noise is about ±10%, so only larger regressions are flagged.

JDK 25.0.5, Linux amd64, 4 CPUs.

Throughput

Workload Base Head Δ
Decode 5k rows (direct) 1.06M rows/s 1.07M rows/s +0.6%
Decode 5k rows (Jackson baseline) 941.3k rows/s 1.04M rows/s +10.0%
Decode 20k rows (direct) 1.04M rows/s 1.06M rows/s +1.5%
Encode one execute (direct) 947.8k req/s 974.2k req/s +2.8%
Encode one execute (Jackson baseline) 990.4k req/s 979.8k req/s -1.1%
JDBC scan + getters (20k rows) 15.80M rows/s 19.83M rows/s +25.5%
JDBC scan + getObject (20k rows) 13.06M rows/s 12.78M rows/s -2.2%

Allocation

Workload Base Head Δ
Decode 5k rows (direct) 8.18 MB/op 8.18 MB/op +0.0%
Decode 5k rows (Jackson baseline) 5.22 MB/op 5.22 MB/op +0.0%
Decode 20k rows (direct) 32.66 MB/op 32.66 MB/op +0.0%
Encode one execute (direct) 1.38 kB/op 1.38 kB/op +0.0%
Encode one execute (Jackson baseline) 1.84 kB/op 1.84 kB/op +0.0%
JDBC scan + getters (20k rows) 1.14 kB/op 1.08 kB/op -4.8%
JDBC scan + getObject (20k rows) 1.12 MB/op 1.12 MB/op +0.0%

Workloads and thresholds live in tools/perf/render-comment.py. Run locally with ./mvnw -pl libsql-perf -am -DskipTests package and see libsql-perf/README.md.

@magnusp
magnusp merged commit 601e8d3 into main Nov 2, 2026
7 checks passed
@magnusp
magnusp deleted the feat/294-transaction-mode branch November 2, 2026 21: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.

1 participant