sqlite3: keep connection handle for in-memory databases across transactions - #342
Merged
penberg merged 3 commits intoSep 2, 2026
Conversation
This was referenced Jul 26, 2026
…ransactions
`Sqlite3Client.transaction()` unconditionally nulled `this.#db` after starting
the BEGIN on the active handle, relying on the lazy `#getDb()` to open a new
connection on the next call. That is safe for file-backed databases, but for
in-memory URLs (`:memory:`, `file::memory:`, `file::memory:?cache=private`)
the database only exists on the open connection — opening a "new" connection
gives you a fresh, empty in-memory database at the same URL, silently
discarding all previously-created schema and data.
Repro (was broken before this change):
const client = createClient({ url: ":memory:" });
await client.execute("CREATE TABLE t (id INTEGER, name TEXT)");
await client.execute("INSERT INTO t VALUES (1, 'a')");
const tx = await client.transaction("write");
await tx.execute("INSERT INTO t VALUES (2, 'b')");
await tx.commit();
await client.execute("SELECT * FROM t");
// LibsqlError: SQLITE_ERROR: no such table: t
Reported in tursodatabase#229 and tursodatabase#140. PR tursodatabase#220 introduced `file::memory:?cache=shared` as
a workaround but left the documented `:memory:` URL broken.
Fix: track `isInMemory` on `Sqlite3Client` (already computed by `_createClient`
via `isInMemoryConfig`) and skip the `this.#db = null` assignment in
`transaction()` for in-memory URLs. The client and the `Sqlite3Transaction`
then share the same `Database` handle, which is the only safe sharing model
for `:memory:`.
Side effects:
- `client.execute(...)` while an in-memory transaction is open now runs on
the transaction's connection (i.e. inside the transaction). This matches
better-sqlite3's single-connection model and is strictly more useful than
the previous behaviour of silently discarding writes.
- Starting a second `client.transaction()` on an in-memory client while
another is still open now throws a clear `SQLITE_ERROR` at BEGIN instead
of silently succeeding against a disposable empty database. The previous
behaviour was never observable anyway — every operation went to a new
empty database.
Tests:
- Updated the existing `:memory:` / `file::memory:?cache=private` transaction
tests that were documenting the broken behaviour (`rejects.toThrow()`) —
they now assert the table survives the transaction, matching the expected
contract.
- Added two positive regression tests against `withInMemoryClient`:
commit-preserves-data and rollback-preserves-original-state.
Ran `npm run typecheck`, `npm run build`, and the full in-memory/file
suites: 171/171 passing, 0 regressions.
Fixes tursodatabase#229, fixes tursodatabase#140.
penberg
force-pushed
the
fix/in-memory-transaction-schema-loss
branch
from
September 1, 2026 10:25
52dede8 to
a590add
Compare
Contributor
|
Thanks @BartWaardenburg! I reworked the fix a bit to keep the in-memory and file-based paths symmetric. |
Contributor
Author
|
Thanks for having a look @penberg. Keep up the lovely work with Turso! |
penberg
force-pushed
the
fix/in-memory-transaction-schema-loss
branch
from
September 1, 2026 11:38
a590add to
a300541
Compare
The previous commit keeps the connection handle for in-memory URLs, which
fixes the data loss but leaves the two database kinds on different models.
The underlying problem is `this.#db = null` in transaction(): the client
hands its connection to the transaction and never gets it back. Nothing
closes it, so every transaction against a file leaks one - 50 committed
transactions leave 50 open fds.
Adopt the model the http and ws clients already use. There, every client
call does openStream() -> work -> closeGracefully(), and transaction() hands
a stream to the transaction, which closes it on commit, rollback and close
alike. Nothing is shared between an open transaction and the rest of the
client, and no resource outlives the call that borrowed it.
Sqlite3Client now owns a ConnectionPool, sized by config.concurrency -
already computed in expandConfig, already used by the remote clients, and
until now ignored here. Client calls borrow a connection and return it in a
finally; a transaction borrows one and returns it when it settles. A
connection is rolled back before it goes back into the pool, so a borrower
can never inherit someone else's transaction.
An in-memory database exists only on the connection that opened it, so an
in-memory client has a pool of exactly one. Embedded replicas likewise, as
each connection carries its own sync state. A single connection cannot serve
a client call while a transaction holds it, and waiting would hang, since
only the caller can end that transaction. The pool tracks which borrows are
transactions and fails such a call immediately with TRANSACTION_ACTIVE and a
message saying what to do. Every other borrow is a short synchronous call
that returns the connection before the caller regains control, so those
still queue - concurrent client reads against `:memory:` keep working.
This removes special cases rather than adding them: no `#db = null`, no
`#getDb()`, no isInMemory flag on the client, and no rewriting of the URL
the caller asked for.
Semantics during an open write transaction:
:memory: file
read untouched table TRANSACTION_ACTIVE ok
read written table TRANSACTION_ACTIVE ok (pre-transaction snapshot)
write / batch / txn TRANSACTION_ACTIVE SQLITE_BUSY
executeMultiple TRANSACTION_ACTIVE ok
File-backed matches the hrana clients: concurrent isolated reads, serialized
writers. An in-memory client refuses overlapping work rather than pretending
to a concurrency the database does not have.
client.execute() during an open transaction no longer joins that
transaction. For files it runs on another connection, as before; for
`:memory:` it is refused rather than silently folded into the transaction.
Tests run against an in-memory and a file-backed client from one table,
with the cases that need a second connection targeted at file-backed.
Fixes tursodatabase#229. Fixes tursodatabase#140.
Co-authored-by: Bart Waardenburg <[email protected]>
penberg
force-pushed
the
fix/in-memory-transaction-schema-loss
branch
from
September 2, 2026 09:23
a300541 to
1174d51
Compare
close() and reconnect() are synchronous and can land in the middle of an operation, in two places: - An operation that is waiting for a connection has its resolver parked in the pool's `#waiters`. Teardown cleared that list without calling anything, so the promise never settled and the caller hung forever. Reachable whenever the pool is saturated - two operations on an in-memory or `concurrency: 1` client, closed before the first releases. - An operation that already holds a connection resumes after the await to find it closed underneath, reaching libsql with a closed handle and failing with a raw TypeError rather than a LibsqlError. Queued waiters are now rejected with CLIENT_CLOSED, and every borrow is rechecked after the await. Reverting either fix fails the new tests.
Contributor
|
I now release 0.18.0 with this fix. Thanks again @BartWaardenburg! |
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.
Summary
Fix silent data loss when calling
client.transaction()against an in-memory URL (:memory:,file::memory:,file::memory:?cache=private). After the transaction starts, subsequentclient.execute(...)calls silently lose all schema and data because the client opens a brand-new, empty in-memory database.Fixes #229. Fixes #140.
Repro (broken on
main)Confirmed against
@libsql/[email protected]and the currentmainbranch.Root cause
In
packages/libsql-client/src/sqlite3.ts,Sqlite3Client.transaction()unconditionally setsthis.#db = nullafter starting theBEGIN, so the next call to#getDb()opens a newDatabase. For file-backed databases this is fine — reopening the file gives you the same data. For in-memory URLs the "database" only exists on the open connection; opening a second connection at:memory:gives you a fresh, empty database. Everything from the original connection becomes unreachable.PR #220 introduced
file::memory:?cache=sharedas a workaround, but the documented:memory:URL remains broken onmain.Fix
Adopt the model the
httpandwsclients already use.There, every client call does
openStream()→ work →closeGracefully(), andtransaction()hands a stream to the transaction, which closes it on commit, rollback and close alike. Nothing is shared between an open transaction and the rest of the client, and no resource outlives the call that borrowed it.Sqlite3Clientnow owns aConnectionPool, sized byconfig.concurrency— already computed inexpandConfig, already used by the remote clients, and until now ignored here. Client calls borrow a connection and return it in afinally; a transaction borrows one and returns it when it settles. A connection is rolled back before it goes back into the pool, so a borrower can never inherit someone else's transaction.async transaction(mode: TransactionMode = "write"): Promise<Transaction> { - const db = this.#getDb(); + this.#checkNotClosed(); + const db = await this.#pool.acquire(true); executeStmt(db, transactionModeToBegin(mode), this.#intMode); - this.#db = null; // A new connection will be lazily created on next use - return new Sqlite3Transaction(db, this.#intMode); + return new Sqlite3Transaction(db, this.#intMode, (used) => + this.#pool.release(used), + ); }This removes special cases rather than adding them: no
#db = null, no#getDb(), noisInMemoryflag on the client, and no rewriting of the URL the caller asked for.Why not just keep the handle for
:memory:The first commit does exactly that, and it fixes the reported bug. But
this.#db = nullis not only wrong for in-memory —Sqlite3Transactionnever closes the connection it was handed and the client has already dropped the reference, so every transaction against a file leaks one:Keeping the handle for
:memory:alone leaves that leak in place and puts the two database kinds on different models.Single-connection databases
An in-memory database exists only on the connection that opened it, so a second connection would be a second, empty database rather than another way into the same one. An in-memory client therefore has a pool of exactly one. Embedded replicas likewise, as each connection carries its own sync state.
A single connection cannot serve a client call while a transaction holds it, and waiting would hang — only the caller can end that transaction. So the pool tracks which borrows are transactions and refuses such a call immediately:
Every other borrow is a short synchronous call that returns the connection before the caller regains control, so those still queue —
Promise.all([...reads])against:memory:keeps working.Behaviour
Measured, during an open write transaction:
:memory:TRANSACTION_ACTIVETRANSACTION_ACTIVETRANSACTION_ACTIVESQLITE_BUSYexecuteMultipleTRANSACTION_ACTIVEFile-backed matches the hrana clients: concurrent isolated reads, serialized writers. An in-memory client refuses overlapping work rather than pretending to a concurrency the database does not have.
Changed behaviour to note in the release notes:
client.execute(...)during an open transaction no longer joins that transaction. For files it runs on another connection, as it did before. For:memory:it is refused, where previously it silently ran against a throwaway empty database.Tests
The tests run against an in-memory and a file-backed client from one table (
describe.each); both helpers ignore$URL, so they run in every CI configuration. Cases that need a second connection are targeted at file-backed, since an in-memory client has only one.Both kinds:
BEGINthroughclient.executedoes not span callsclient.close()with an open transaction does not crashclient.close()andclient.reconnect()settle operations that are waiting for a connection, rather than leaving them pending foreverFile-backed:
client.executeduring a transaction runs outside itIn-memory:
file::memory:?cache=privatesurvives a transactionVerification
Rebased onto
mainat 0.17.4.Skipped tests need the Python hrana server.
Notes
345ms→1354ms). Connections exist for isolation, not parallelism.journal_modeisdelete), which is why a file-backed write blocks even against a deferred read transaction. Changing that is its own PR.Follow-ups (not in this PR)
packages/libsql-client-wasm/src/wasm.ts:199has the identicalthis.#db = nullline and the identical leak.reconnect()on an in-memory database still opens a fresh connection.URL="file::memory:?cache=shared"fails ontable t already exists, because that database is process-global and tests collide. Pre-existing onmain; not a CI configuration.