Skip to content

fix(adapter-libsql): clean up failed commits - #30071

Open
GiHoon1123 wants to merge 1 commit into
prisma:v7from
GiHoon1123:fix/adapter-libsql-zombie-transaction
Open

fix(adapter-libsql): clean up failed commits#30071
GiHoon1123 wants to merge 1 commit into
prisma:v7from
GiHoon1123:fix/adapter-libsql-zombie-transaction

Conversation

@GiHoon1123

@GiHoon1123 GiHoon1123 commented Aug 19, 2026

Copy link
Copy Markdown

Related to #30028

When the libsql adapter uses phantom transactions, the transaction manager delegates the actual COMMIT/ROLLBACK calls to the adapter. If COMMIT fails, the underlying libsql transaction can remain open unless the adapter explicitly cleans it up.

This updates LibSqlTransaction.commit() to attempt a rollback after a failed commit. If that cleanup rollback also fails, it closes the transaction handle as a fallback while preserving the original commit error.

This PR is limited to cleaning up the transaction after a failed COMMIT. SQLITE_BUSY error classification is outside the scope of this change.

Tests:

  • pnpm --filter @prisma/adapter-libsql test
  • pnpm --filter @prisma/adapter-libsql build

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 42ac2a99-45ec-4ced-83d2-93e3f13a4835

📥 Commits

Reviewing files that changed from the base of the PR and between 05ce490 and b2a2fc5.

📒 Files selected for processing (2)
  • packages/adapter-libsql/src/libsql.test.ts
  • packages/adapter-libsql/src/libsql.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

Changes

LibSQL commit failure recovery

Layer / File(s) Summary
Commit cleanup and failure coverage
packages/adapter-libsql/src/libsql.ts, packages/adapter-libsql/src/libsql.test.ts
Failed commits attempt rollback. If rollback fails, the transaction client is closed and secondary failures are logged. The original commit error is rethrown. Tests cover successful rollback and rollback-plus-close failures.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to b2a2f

The change cleans up libsql transactions after failed commits while preserving the original error, with focused tests and build coverage; no actionable merge-blocking risk remains.

Possibly related PRs

  • prisma/prisma#28768: Both changes handle rollback and resource cleanup after transaction failures.
  • prisma/prisma#29611: Both changes attempt rollback and close cleanup when transaction cleanup fails.
  • prisma/prisma#29955: Both changes recover from commit failures while preserving the original error.

Suggested labels: lgtm

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR fixes failed-commit cleanup but does not implement the linked issue's separate requirement for a dedicated SQLITE_BUSY error kind. Add the required SQLITE_BUSY error classification, or explicitly narrow the linked issue scope if another change handles it.
✅ Passed checks (4 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The implementation and tests remain focused on failed-commit rollback, transaction closure, and preservation of the original error.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: cleanup for failed commits in the libsql adapter.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@UgaTheDev

Copy link
Copy Markdown

Nice find — this is a real leak and the fix is correct. I reproduced it end to end before writing this, so here is the evidence in case it is useful for the maintainer review.

The premise checks out, and there is a strong argument for it in-tree

The comment in the diff says the transaction manager never sends a real COMMIT/ROLLBACK for phantom-query adapters. That is exactly what transaction-manager.ts does, and the contrast inside that one function is the best case for this patch:

// packages/client-engine-runtime/src/transaction-manager/transaction-manager.ts:534
if (tx.transaction.options.usePhantomQuery) {
  await this.#withQuerySpanAndEvent(PHANTOM_COMMIT_QUERY(), tx.transaction, () => tx.transaction!.commit())
} else {
  const query = COMMIT_QUERY()
  await this.#withQuerySpanAndEvent(query, tx.transaction, () => tx.transaction!.executeRaw(query)).then(
    () => tx.transaction!.commit(),
    (err) => {
      const fail = () => Promise.reject(err)
      return tx.transaction!.rollback().then(fail, fail)   // <-- exactly this PR's cleanup
    },
  )
}

The non-phantom branch already does rollback-on-failed-commit-and-rethrow-the-original-error. The phantom branch does not. adapter-libsql sets usePhantomQuery: true (libsql.ts:188), so it lands in the branch with no cleanup — and since it owns the real COMMIT, it is the only place that can clean up. So this PR restores parity with a pattern already in the codebase rather than inventing one.

Reproduced with a real commit failure on a real libsql database

I forced a genuine COMMIT failure — a deferred foreign key violation on a file: libsql DB (PRAGMA defer_foreign_keys = ON, insert a child row with a dangling FK, then commit) — and watched the write lock from a second, independent @libsql/client connection to the same file, opened before the transaction started so the measurement is not confounded by connection setup.

stage without the fix (base 05ce49014) with the fix (b2a2fc5)
baseline, before any tx write OK write OK
during the open write tx SQLITE_BUSY SQLITE_BUSY
after the failed commit SQLITE_BUSY write OK
after the failed commit, retried SQLITE_BUSY write OK

Without the patch the second connection is locked out permanently, not transiently. The commit error surfaces as LibsqlError / SQLITE_CONSTRAINT_FOREIGNKEY (rawCode 787) and the write transaction stays open forever. With the patch the same sequence recovers. Instrumenting the underlying client confirms the intended path runs and nothing else does:

["commit:called", "commit:threw(SQLITE_CONSTRAINT)", "rollback:called", "rollback:ok"]

Confirming the mechanism at the driver level, Sqlite3Transaction.commit() is just #checkNotClosed() + executeStmt("COMMIT") with no cleanup, and closed is derived from !database.inTransaction — so after a failed COMMIT the transaction is genuinely still open and rollback() is still a legal call. Measured directly: tx.closed === false after the failed commit, true after the cleanup rollback.

Failure-mode sweep

I walked every way the commit path can fail rather than only the one the PR names:

failure mode cleanup runs?
commit() rejects yes — rollback, verified against a real DB
commit() rejects and cleanup rollback() rejects yes — falls through to close(), original commit error preserved
all three reject (commit, rollback, close) yes — both secondary errors go to debug, original error still rethrown
underlying connection already disposed no crash; behaviour unchanged from before this PR
commit() never settles not handled — see note 2

The finally { this.#unlockParent() } placement is right: the parent mutex is released on every one of these paths, and I confirmed a subsequent startTransaction() on the same adapter still succeeds after a failed commit rather than deadlocking. Rethrowing error rather than the rollback error is also the right call — the commit failure is the one the caller can act on.

On the remote transports this is a safe no-op rather than a behaviour change, which is worth knowing: HranaTransaction.commit() already has finally { this.close() }, so after a failed commit the stream is closed, and the new rollback() returns early on stream.closed without throwing. So the patch only changes anything for the local sqlite3 transport — which is precisely where the SQLite write lock exists.

Tests: the two new cases fail on the base commit (4 failures, once per describe.each variant, expected "vi.fn()" to be called 1 times, but got 0 times) and pass on the PR head, and the full adapter-libsql suite is green at 31/31. So they are genuine regression tests, not tests written to match the implementation.

Two optional notes, neither blocking

1. rollback() ten lines below has the same gap this PR is fixing. The catch in rollback() logs and moves on with no close() fallback, so a failed ROLLBACK leaves the connection in exactly the open write transaction this PR is closing off — and silently, since #unlockParent() still runs and the method still resolves. I exercised it through the same mock harness the PR's tests use: after a rejected rollback(), close() is never called and the adapter happily hands out the next transaction. The trigger is much rarer than a failed COMMIT (on the local transport ROLLBACK effectively only fails if the connection is gone, and on hrana rollback() self-closes in its own finally), so this may well be deliberate — but if the maintainers want symmetry, the same three-line try { close() } would cover it.

2. Where should this live? adapter-mssql has the identical shape and the identical gap:

// packages/adapter-mssql/src/mssql.ts:101
async commit(): Promise<void> {
  const release = await this.#mutex.acquire()
  try {
    await this.transaction.commit()
  } finally {
    release()          // no rollback cleanup if commit() threw
  }
}

For completeness on the other phantom-query adapters: adapter-d1 (d1-http.ts:195, d1-worker.ts:115) has empty commit()/rollback() bodies and adapter-better-sqlite3 sets usePhantomQuery: false, so neither can leak this way; adapter-mariadb releases the pooled connection in a finally regardless of outcome; adapter-planetscale delegates to its own driver's transaction. So libsql and mssql are the two that own a real interactive COMMIT with no cleanup. That might argue for the guard living in the transaction manager's phantom branch instead, next to the non-phantom branch that already has it — but that is a larger call than this PR, and a targeted adapter-level fix with tests seems like the right shape for a bug fix. Flagging it only so the maintainers can decide, and so the mssql case does not get lost.

Also worth noting for whoever merges: the diff's inline comment is unusually good at explaining why this is the only place that can clean up. Please keep it.

Nothing here needs to change for this to ship, in my read. Not a maintainer — just wanted to hand over the reproduction so the review is cheaper.

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.

3 participants