Skip to content

fix: open real transactions when no boundary exists - #3438

Merged
chubes4 merged 1 commit into
mainfrom
fix-3436-sqlite-bare-savepoint
Sep 3, 2026
Merged

fix: open real transactions when no boundary exists#3438
chubes4 merged 1 commit into
mainfrom
fix-3436-sqlite-bare-savepoint

Conversation

@chubes4

@chubes4 chubes4 commented Sep 2, 2026

Copy link
Copy Markdown
Member

Closes #3436

Problem

TransactionScope::begin() issued a bare SAVEPOINT on SQLite with no enclosing transaction, then relied on RELEASE SAVEPOINT to commit it. That sequence is invalid on MySQL and is becoming invalid on SQLite.

Verified on MariaDB 10.11.14 with autocommit enabled:

SAVEPOINT sp1;
SELECT @@in_transaction;   -- 0, no transaction was opened
UPDATE ...;
RELEASE SAVEPOINT sp1;
-- ERROR 1305 (42000): SAVEPOINT sp1 does not exist

WordPress/sqlite-database-integration#496 aligns SQLite with those semantics. Against its head (3cc53c7), our exact sequence fails: RELEASE SAVEPOINT returns 1305 so commit() reports failure, and on the rollback path both ROLLBACK TO SAVEPOINT and RELEASE SAVEPOINT fail while the write stays applied.

Why the obvious fix was not enough

Three findings changed the shape of this patch. Details and probe output in the issue comment.

  1. Two special-cases, not one. Line 25 seeded $in_transaction = is_sqlite(), which already skipped the probe. The || is_sqlite() on line 38 was redundant, so removing only it would have fixed nothing.
  2. @@autocommit is NULL on SQLite. The adapter does not implement it. (int) null === 0, and the check was 0 === (int) ..., so even with both special-cases removed the probe still concluded "caller owns a transaction" and still opened a bare savepoint.
  3. Naive START TRANSACTION is worse. A plain START TRANSACTION is invisible to every server variable on SQLite, and a second one implicitly commits the first. Measured: outer write survived an inner rollback. That silently converts a nested rollback into an early commit.

Change

  • Remove both BaseRepository::is_sqlite() special-cases.
  • Treat only a non-NULL zero @@autocommit as an enclosing transaction.
  • Track scope depth in process, because no server variable reports it portably.
  • Release depth from a destructor, so a scope abandoned by an early return or exception does not leave the depth raised and strand later scopes.

Savepoints are still used for genuinely nested scopes. Commit, rollback, ownership, and retry behavior are unchanged.

Verification

Patched code, four scenarios, identical on MariaDB 10.11.14 and SQLite at both 3cc53c7 (PR #496 head) and 48c457e:

case result
single scope commit committed
single scope rollback prior value retained
inner rollback outer write preserved
outer rollback discards committed inner prior value retained

Baseline against 3cc53c7 fails all four (commit: false, rolled_back, inner, inner2).

Without the destructor, an abandoned scope causes the next independent scope to emit a bare savepoint again; with it, that scope correctly opens START TRANSACTION.

WordPress test isolation is preserved: the test framework issues SET autocommit = 0, which the adapter does persist, so tests continue to take the savepoint path.

Tests

Added coverage that fails on the current code:

  • test_absent_autocommit_variable_is_not_read_as_open_transaction — the core regression; fails on baseline, which emits SAVEPOINT instead of START TRANSACTION.
  • test_disabled_autocommit_opens_savepoint_scope / test_enabled_autocommit_opens_transaction_scope — pin both probe branches.
  • test_abandoned_scope_does_not_strand_later_scopes — depth release.
  • test_inner_scope_rollback_preserves_outer_scope_write / test_outer_scope_rollback_discards_committed_inner_scope — nesting guards.

The autocommit-branch tests use a small recording double because the live test connection always runs inside the WordPress test transaction, which makes the autocommit-enabled branch unreachable through $GLOBALS['wpdb'].

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Code
  • Used for: Verifying MySQL and SQLite savepoint semantics, reproducing against the upstream PR head, implementing the fix, and writing the tests.

TransactionScope forced the savepoint path on SQLite and treated an
unsupported @@autocommit as autocommit disabled, so it issued a bare
SAVEPOINT with no enclosing transaction.

Engines that do not implement @@autocommit report NULL, which casts to 0
and was read as "a caller owns the outer transaction". Only a non-NULL
zero now counts, and the driver-specific shortcut is gone, so a scope
opens a real transaction when nothing encloses it.

A bare SAVEPOINT is invalid on MySQL: it opens no transaction, and
releasing it fails with error 1305. Upstream sqlite-database-integration
is aligning with those semantics, which turns the old path into failed
commits and rollbacks that silently leave writes applied.

Nesting can no longer be inferred from server variables, because a plain
START TRANSACTION is invisible to them on SQLite and a second one is an
implicit commit of the first. Scope depth is tracked in process instead,
and unresolved scopes release their depth so later scopes still open a
real boundary.

Refs #3436
@chubes4
chubes4 merged commit 2e5903e into main Sep 3, 2026
30 checks passed
@chubes4
chubes4 deleted the fix-3436-sqlite-bare-savepoint branch September 3, 2026 15:52
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.

TransactionScope issues a bare SAVEPOINT on SQLite without an active transaction

1 participant