Skip to content

fix(editor): decide a batch's transaction from the engine, the script and the session - #2971

Merged
datlechin merged 4 commits into
mainfrom
fix/batch-transaction-and-legacy-mysql-gaps
Sep 18, 2026
Merged

datlechin merged 4 commits into
mainfrom
fix/batch-transaction-and-legacy-mysql-gaps

Conversation

@datlechin

Copy link
Copy Markdown
Member

Nine defects found while investigating #2949, fixed together because six of them meet in one function: the multi-statement runner in the SQL editor.

What was wrong

The batch wrapped everything in a transaction, whatever the script or the connection was doing.

Measured
A statement the engine will not run inside a transaction PostgreSQL VACUUM, CREATE INDEX CONCURRENTLY, ALTER SYSTEM, DISCARD ALL; SQLite VACUUM, DETACH, PRAGMA journal_mode; DuckDB CHECKPOINT; MySQL SET sql_log_bin (error 1694, the first line of a GTID mysqldump). PRAGMA foreign_keys was worse: accepted, silently ignored
A transaction the user already had open PostgreSQL: the batch's COMMIT committed their work, its ROLLBACK discarded it. MySQL: START TRANSACTION committed it and released LOCK TABLES. SQLite refused the nested BEGIN; DuckDB and CockroachDB aborted the user's transaction
Stop during the last statement The kill landed after the statement finished, so the batch committed anyway while the UI said "cancelled by user"
A connection lost during COMMIT Reported as "The transaction could not be committed" and a rollback that could not arrive, over a transaction the server may well have kept
A query in another tab Cancellation was keyed by connection, so any tab, any window, an MCP client or an AI statement cancelled and rolled back a running batch, reported as "cancelled by user"
Redis The batch became MULTI/EXEC: every result read QUEUED, DEL reported 0, and EXEC's per-command errors were thrown away

And four legacy-server defects, each of which reported success:

  • The query timeout was sent as SET SESSION max_execution_time (MySQL) or max_statement_time (MariaDB) whatever the server was. MySQL before 5.7.8 and MariaDB before 10.1.1 answer ERROR 1193, so there was no timeout at all; the socket timeout then fired and the driver re-ran the statement while the first copy kept running on the server.
  • Adding a check constraint on MySQL before 8.0.16 or MariaDB before 10.2.1 reported success. Those servers parse CHECK and discard it.
  • Users & Roles sent ALTER USER … IDENTIFIED BY and WITH MAX_USER_CONNECTIONS, which MySQL before 5.7.6 and MariaDB before 10.2 reject.
  • On iPhone and iPad a write opened a bare START TRANSACTION, so a server whose sessions default to read-only refused it with 1792 where the Mac succeeded.

What it does now

One decision, made once per run. BatchTransactionPolicy.plan(for:databaseType:rules:) returns a BatchTransactionPlan:

  • appTransaction: the batch owns a transaction, as before.
  • scriptTransaction: the script opens its own (BEGIN, START TRANSACTION, XA START, SET autocommit), so nothing is wrapped around it.
  • autocommit: the script holds a statement the engine refuses inside a transaction, so every statement commits as it runs.
  • sessionTransaction: the connection already has one open, so the batch joins it and sends no BEGIN, COMMIT or ROLLBACK.

The engine rules are per family (TransactionEngineFamily, curated by DatabaseType, so DuckDB is not SQLite and SQL Server is not generic) and read with a lazy SQLTokenCursor that never copies the statement: 100,053 statements of a real mysqldump classify in 80ms. Redis is a family whose batch is never wrapped.

Session state is a driver question, so PluginKit gained sessionTransactionState() with an .unknown default: libpq from PQtransactionStatus, MySQL from the server's SERVER_STATUS_IN_TRANS flag, SQLite from sqlite3_get_autocommit, DuckDB and SQL Server through their own probes. Grid saves, structure changes and a Users & Roles apply take the same answer, and report their statements as pending rather than written when they join a transaction the user must still commit.

The commit is a point of no return, and the UI says so. It runs shielded from cancellation, Stop is unavailable for that one round trip, and a lost connection there reports that the statements may or may not be saved rather than claiming a rollback.

Cancellation is per tab. TabQueryTasks replaces the window's single query task, cancelRunningQuery reaches only the owner's leases, and a second tab waits for the connection instead of stopping the first.

Legacy servers: the timeout enforcement comes from the server's own answer (ERROR 1193 switches to a client-side deadline that kills the statement from a second connection), a statement that outlasts the socket timeout is never replayed and its orphan is killed, check-constraint editing is withdrawn with the reason where the server discards it, account statements fall back to GRANT USAGE … IDENTIFIED BY, and iOS opens its write transactions read-write.

PluginKit

Version 33, additive: sessionTransactionState() and checkConstraintRefusal, both with defaults, plus one non-frozen enum. scripts/check-pluginkit-abi.sh against origin/main reports those additions and no removals, so minimumCompatiblePluginKitVersion stays 19 and nothing needs re-publishing. DuckDB and SQL Server are registry-only, so they answer .unknown until their plugins are next published at kit 33.

Verification

  • Build PASS; all 41 plugins PASS; iOS simulator build PASS; check-ios-shared-isolation.py PASS.
  • Unit: 735 cases across 56 suites, PASS, plus 301 more over the suites a cold-read review pass touched.
  • UI: QueryRunUITests and QueryErrorBannerUITests, 22 cases, PASS, including four new end-to-end cases on the SQLite sample (a script with its own BEGIN, a failed script's transaction rolled back, PRAGMA foreign_keys applied in the same script, VACUUM in a batch).
  • swiftlint --strict over all 181 changed Swift files: clean of anything this branch introduced.
  • Docs: check-writing-style.sh, check-docs-against-source.py, mint validate and mint broken-links all pass.
  • Three committed probe scripts re-check the curated tables against a live server: check-mysql-autocommit-only-variables.sh, check-mysql-query-timeout.sh, check-redis-multi-semantics.sh.

Measured against MySQL 5.5.62, 5.6.51, 5.7.44, 8.4.11, 9.7.2, MariaDB 5.5.64, 10.0.38, 10.1.48, 10.6.28, 11.4.13, 12.3.3, Percona 5.5.61, PostgreSQL 17.11, CockroachDB 25.2.23, SQLite 3.54.0, DuckDB 1.5.4 and Redis 8.10.1.

Known gaps

  • A user Stop shows no results for the statements that already committed under autocommit; the rows are in the database and a refresh shows them.
  • Engines whose drivers cannot report session state (Oracle, Snowflake, Spanner, Teradata, Dameng, LibSQL, OceanBase, Databend, the SQLite SSH agent backend) still wrap a plain batch as before.
  • SQL Server and Redshift rules come from their documentation; neither server was available here.
  • SQL Import still wraps a whole file in one transaction, so a GTID mysqldump restored through File > Import still hits error 1694. It streams the file, so it needs its own fix.
  • No UI automation for the per-tab cancellation flow: with two editors mounted, UITestCase's editor lookup is ambiguous and the tab strip's accessibility snapshot stops refreshing while the connection is busy. That second point may be a real defect of its own and is worth a look.

Review

A cold-read review of the whole diff found six defects, all fixed here with regression tests: a failed statement clearing the MySQL session-lock flag (so the idle release could hand back a connection holding the user's lock), a second timeout statement's ERROR 1193 installing a client-side kill on top of a working server one, a Redis command on the streamed route throwing instead of reporting QUEUED, object copy wrapping a transaction the session already held, Stop removing a batch's cancellation handle before deciding the commit was uninterruptible, and a cancelled Fetch All never retiring its task entry. The last of those is pinned by a test verified to fail without the fix.

Codex could not be used for the review: the account is over its usage limit until 22 September.

…-and-legacy-mysql-gaps

# Conflicts:
#	Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift
#	Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
#	TablePro/Core/Coordinators/PaginationCoordinator.swift
#	TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift
#	TablePro/Core/Plugins/PluginManager.swift
#	TablePro/Views/Main/MainContentCoordinator.swift
@mintlify

mintlify Bot commented Sep 18, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 18, 2026, 1:30 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit 0968dab into main Sep 18, 2026
12 of 14 checks passed
@datlechin
datlechin deleted the fix/batch-transaction-and-legacy-mysql-gaps branch September 18, 2026 05:16
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