fix(backend): per-wire-connection sessions for real transaction isolation - #6
Merged
Merged
Conversation
luthermonson
force-pushed
the
perf/translate-cache-and-prepare
branch
from
July 10, 2026 04:15
00e3997 to
c00b26d
Compare
…tion Before this change every wire frontend shared a single Arc<dyn Backend> whose only handle was one rusqlite::Connection (or one un-batoned Hrana stream). Two consequences: 1. Correctness: client A's BEGIN was visible to every other client; client B's statements landed inside A's open transaction; either client's COMMIT finalized both. The Mutex serialized calls but did nothing to isolate per-session transaction state -- that's a property of the *underlying connection*, not the Rust lock. 2. Performance: WAL's concurrent-reader model was defeated by the process-wide Mutex on the shared Connection. Fix: split Backend into a factory that hands out per-session BackendConn instances. Each MySQL / Postgres / TDS handler calls Backend::connect() at session start and drops the conn on disconnect. Backend::query/execute/describe_columns stay on the trait as stateless shims that open-and-drop a fresh conn per call, so callers like TrackedBackend that don't need transactions keep working with a one-line diff (none needed, actually -- they use the default impls). - Rusqlite: one Connection per session against the same DB file; SQLite WAL + busy_timeout handles cross-conn coordination without a Rust-side lock. prepare_cached (from the perf branch this is stacked on) is now naturally per-session, which is where its LRU wants to live. - Rusqlite in-memory: memory DBs back themselves with an internally- managed temp file (deleted on Rusqlite drop). Shared-cache in-memory mode was the obvious alternative but it *defeats* WAL and reintroduces the LOCKED-under-writer regression this PR is fixing. Documented in the module header. - Hrana: HranaConn carries the sqld-issued baton across calls so all statements from one wire client stay on the same sqld stream (sqld's own transaction isolation is per-stream). Drop sends a best-effort Close so sqld reclaims stream state promptly. - MySQL / PG / TDS frontends: switched to per-connection factory pattern. Postgres previously reused one Arc<PostgresHandler> across all pgwire clients via the factory; that's fixed too. Isolation tests: - per_conn_transaction_isolation (rusqlite unit): A's uncommitted INSERT is invisible to B; B's rogue COMMIT doesn't touch A's tx; after A commits both see the row. This is the exact scenario the old code corrupted. - per_conn_rollback_stays_local: A's rolled-back tx leaves no residue on either A or B. - per_conn_concurrent_readers: 16 tasks * 50 SELECTs against distinct sessions complete without SQLITE_BUSY / SQLITE_LOCKED. - per_conn_shared_memory_visibility: two sessions on Rusqlite::memory() see each other's committed schema and rows -- proves the temp-file redirection preserves the API. - per_conn_beats_reopen_on_hot_selects: measures 200 point-selects via the stateless shortcut vs one held BackendConn. On this box (release build): stateless=315ms, per_conn=6ms, ratio ~52x. Asserts only that per_conn is not slower. - two_client_transaction_isolation (MySQL e2e via mysql_async): drives the whole stack -- two real TCP connections, MySQL wire in, SQLite out. B does not see A's uncommitted row; A can still commit. - two_client_independent_transactions (MySQL e2e): both clients open distinct transactions concurrently, which would have failed under the shared-conn bug with 'cannot start a transaction within a transaction'. Cross-crate impact: - BackendConn is a new trait; Backend::connect() is required on new Backend impls. Existing callers of Backend::query/execute do not need changes. - ephpm's TrackedBackend (in the ephpm/ephpm repo) only overrides Backend::query/execute; it now transparently opens a fresh session per stateless call. If ephpm ever wants transaction correctness for the wrapped backend it should call TrackedBackend::connect() through the trait and wrap the returned BackendConn -- follow-up PR.
luthermonson
force-pushed
the
fix/per-connection-backend
branch
from
July 10, 2026 04:18
817f373 to
19093f9
Compare
luthermonson
changed the base branch from
perf/translate-cache-and-prepare
to
main
July 10, 2026 04:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
crates/litewire-backend/src/rusqlite_backend.rs(pre-PR): oneArc<Mutex<Connection>>perRusqliteinstance, shared across every accepted wire connection.crates/litewire-mysql/src/handler.rs(and its Postgres / TDS peers) trackedin_transaction: boolper wire connection in Rust, but the actualBEGINran on the shared rusqlite handle. Consequences:BEGINopened a transaction on the shared handle, so client B's next statement landed inside A's transaction. Either client'sCOMMITfinalized the other's writes.Mutexserialized every backend call. SQLite WAL's concurrent-reader property was defeated by the Rust lock before it ever reached the storage layer.Additionally, Hrana:
HranaClient::execute_pipelinesentbaton: Noneon every call, so all wire clients collided on sqld's default stream and inherited the same transaction-scoping problem.The fix
Split
Backendinto a factory for per-session handles:Each
LiteWireHandler/PostgresHandler/ TDShandle_connectioncallsBackend::connect()at session start and holds theBox<dyn BackendConn>for the lifetime of the client.rusqlite backend
connect()opens a freshrusqlite::Connectionagainst the same DB file.busy_timeout(default 5000ms, configurable viaRusqliteBuilder) handles the SQLITE_BUSY retries that used to be silently masked by the process-wide Mutex.synchronous=NORMAL(WAL-appropriate) is the default; configurable.prepare_cached(from the perf branch) now lives naturally on the per-sessionRusqliteConn-- its LRU is per-connection in rusqlite anyway.In-memory database caveat (called out in the module docs)
rusqlite::Connection::open_in_memory()gives every connection a distinct database, which breaks the whole model. The obvious workaround -- shared-cache URI (file:name?mode=memory&cache=shared) -- defeats WAL and reintroducesSQLITE_LOCKEDunder writers, which is exactly the regression this PR is fixing. I proved that with a failing test before switching approaches.Chosen fix:
Rusqlite::memory()transparently backs itself with a per-process temp file (deleted onRusqlitedrop, via aTempOwnerhandle that also removes the-wal/-shmsidecars). Consumers keep the exact API. This gives real WAL semantics and real per-connection isolation for tests.Hrana backend
HranaConncarries the sqld-issued baton across calls so all statements from one wire client stay on the same sqld stream (sqld does its own per-stream transaction isolation).Dropsends a best-effortCloserequest so sqld reclaims the stream promptly -- the runtime handle guard means this is safe to construct outside a runtime (unit tests).Frontend changes
crates/litewire-mysql):LiteWireHandler::newis nowasyncand takes out the session; on failure the accept task logs and returns without serving.crates/litewire-postgres): previously oneArc<PostgresHandler>was reused across every pgwire client via the factory. Now each accept spins up its ownPostgresHandlerinside a per-connectionLiteWireHandlerFactory.crates/litewire-tds):handle_connectionopens the session after Login7 and threads&dyn BackendConnthrough the sub-handlers instead of&SharedBackend.Tests
Unit (
litewire-backend):per_conn_transaction_isolation-- A's uncommittedINSERTinvisible to B; B's rogueCOMMITdoesn't touch A's tx; after A commits both see the row. This is the exact scenario the old code corrupted.per_conn_rollback_stays_local-- A rolls back, no residue anywhere.per_conn_concurrent_readers-- 16 tasks * 50 SELECTs on distinct sessions complete withoutSQLITE_BUSY.per_conn_shared_memory_visibility-- two sessions onRusqlite::memory()see each other's committed schema/rows (proves the temp-file redirection preserves API semantics).per_conn_beats_reopen_on_hot_selects-- 200 point-selects via the stateless shortcut vs one held BackendConn.E2E (
crates/litewire/tests/mysql_e2e.rs, real TCP +mysql_async):two_client_transaction_isolation-- drives the whole stack. B does not see A's uncommitted row.two_client_independent_transactions-- both clients issueBEGINconcurrently. Under the old shared-conn bug B'sBEGINwould fail with 'cannot start a transaction within a transaction'; here it succeeds and stays isolated.Full workspace: 283 tests, 0 failures. Clippy
-D warningsclean.Concurrency measurement
per_conn_beats_reopen_on_hot_selectsrunning in release mode on the dev box:The stateless shortcut (fresh conn per call, mimicking the old post-Mutex-drop hot path) does 200 point-selects in ~316ms; a held
BackendConndoes the same in ~6ms. That's ~52x -- driven by both the removed re-open cost per call and byprepare_cachedLRU hits on the held session. The test asserts only 'per_conn <= stateless * 2' so machine noise doesn't flake CI.Cross-crate impact (ephpm/ephpm repo)
ephpm-server/src/tracked_backend.rsoverridesBackend::query/execute(notconnect), so it now transparently opens a throw-away session per call via the trait defaults. That preserves the existing 'wrap for stats' semantics without change. When ephpm wants transaction correctness for wrapped backends, it will need to expose aTrackedBackendConn-- follow-up PR.What's deliberately not in scope
base_urlredirects on the Hrana side (accepted, not acted on -- noted in the struct).busy_timeouthandles this; the wire code just surfacesBackendError).