Skip to content

Refuse XA workloads in the binlog stream instead of applying prepared rows - #1079

Draft
morgo wants to merge 3 commits into
block:mainfrom
morgo:xa-transactions-guard
Draft

Refuse XA workloads in the binlog stream instead of applying prepared rows#1079
morgo wants to merge 3 commits into
block:mainfrom
morgo:xa-transactions-guard

Conversation

@morgo

@morgo morgo commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

Both change clients treated an XA transaction's prepare-time row events as committed. MySQL writes an XA transaction's entire first binlog group at XA PREPARE time — GTIDEvent → Query("XA START") → row events → Query("XA END") → XA_PREPARE_LOG_EVENT — before the transaction's outcome is known. Spirit buffered those row events like any committed transaction and flushed them to the target (the GTID client even promoted the prepare-group GTID into its resume set at the XA_PREPARE_LOG_EVENT).

If the transaction was later terminated with XA ROLLBACK, nothing in the binlog undoes those rows: the target diverges permanently, detectable only by checksum — and only if the affected chunk is checksummed after the rollback. The pre-existing test suite explicitly documented the hazard ("a later XA ROLLBACK of the prepared transaction would not be compensated").

Both replication paths were exposed: the GTID client had explicit XA handling that applied prepared rows, and the file/offset client had no XA handling at all — XA statements fell through to the DDL parser (unparseable → skipped) while the prepare-group's row events were buffered and applied identically.

Fix: fail fast, not full XA support

Full XA support (tracking prepared XIDs, buffering until XA COMMIT/XA ROLLBACK, undoing on rollback) is out of scope. Instead, spirit now refuses XA workloads outright — the same posture as the preflight refusal of non-empty binlog_row_value_options:

XA transactions detected in the binlog stream: spirit does not support XA workloads

Any XA statement observed as a QueryEvent (XA START, XA END, XA COMMIT, XA ROLLBACK) and any XA_PREPARE_LOG_EVENT fails the stream in both clients, for uncompressed streams and binlog_transaction_compression payloads alike. The abort propagates through the existing fatal stream-error path (FatalReasonStreamError — the same one the minimal-RBR runtime guard uses), so the migration stops cleanly and the checkpoint is preserved.

Why the abort provably happens before divergence

Rows reach the target only via subscription buffers that a flush later applies. The guard fires at the group's opening "XA START" QueryEvent, which the server writes ahead of the transaction's row events (verified against MySQL 8.0, uncompressed and compressed): the stream is torn down before any of those row events are decoded or buffered, so no flush — concurrent or later — can ever apply them. There is no race window to lose. The prepare-group GTID (and file/offset position) is deliberately not advanced past the refused group, so a checkpoint resume replays the group and re-refuses it rather than skipping its rows. The XA_PREPARE_LOG_EVENT branches are defense in depth in case a future server version reshapes the group.

Terminal XA COMMIT / XA ROLLBACK statements with no preceding in-stream XA START (a transaction prepared before spirit connected) are refused too: their row events were never streamed and may postdate the copier's snapshot of their chunk, so an applied commit could silently lose rows.

Behavior notes

  • XA COMMIT ... ONE PHASE (atomic, no prepared state) previously replicated correctly and is now also refused — deliberately, because at "XA START" time the one-phase outcome is unknowable, and only refusing there guarantees no prepared rows can ever be flushed.
  • On resume, if the refused XA group is still inside the replay window the run fails again with the same error (same operational contract as the existing binlog_row_image=FULL runtime guard); once the workload stops using XA, a fresh start proceeds normally.

Why runtime-only (no preflight)

There is no reliable server-level "workload uses XA" signal to check up front: XA cannot be disabled server-side, and XA RECOVER requires the XA_RECOVER_ADMIN privilege while only showing transactions prepared at that instant — an empty result says nothing about what the workload will do mid-migration. The runtime guard is deterministic and fires before any divergence is possible, so a preflight would add a privilege requirement without adding safety.

Tests

  • End-to-end (real server, two-phase and one-phase XA, plus binlog_transaction_compression): the guard fires as a checkpoint-preserving stream error at prepare time, zero row events buffered, and the target contains nothing after XA ROLLBACK — for both the GTID and file/offset clients.
  • Deterministic synthetic-streamer tests for each guard signal (XA START, lone terminal XA COMMIT/XA ROLLBACK, lone XA_PREPARE_LOG_EVENT), asserting the fatal reason, empty buffers, and that the XA GTID never enters the resume set.
  • Direct unit tests of the statement classifier and the compressed-payload guards in both clients (including non-XA lookalikes such as a table named xa).

🤖 Generated with Claude Code

… rows

Both change clients treated an XA transaction's prepare-time row events
as committed: the row images are written to the binary log at XA PREPARE
time and were buffered and flushed to the target like any commit. If the
transaction was later terminated with XA ROLLBACK, nothing in the binlog
undoes those rows, so the target diverged permanently - detectable only
by checksum, and only if the affected chunk was checksummed after the
rollback.

Full XA support (tracking prepared XIDs and buffering until the XA
COMMIT / XA ROLLBACK outcome) is out of scope, so fail fast instead,
matching the existing posture of refusing binlog_row_value_options: any
XA statement (XA START / XA END / XA COMMIT / XA ROLLBACK QueryEvents)
or XA_PREPARE_LOG_EVENT observed in the stream fails the migration with
"XA transactions detected in the binlog stream: spirit does not support
XA workloads", surfaced through the existing fatal stream-error path
(checkpoint preserved).

The guard fires at the group's opening "XA START" QueryEvent, which the
server writes ahead of the transaction's row events, so none of them are
ever buffered - a concurrent flush therefore cannot apply them, and the
resume coordinate never advances past the refused group.

Applies to both the GTID and file/offset clients, for uncompressed
streams and binlog_transaction_compression payloads alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@morgo
morgo marked this pull request as ready for review July 24, 2026 01:10
@morgo
morgo requested a review from Copilot July 24, 2026 01:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds runtime protection to Spirit’s binlog change stream so that any XA activity causes a fail-fast, checkpoint-preserving abort, preventing permanent target divergence from prepare-time row events being applied before an XA outcome is known.

Changes:

  • Introduce a shared XA detection helper/error and use it to abort both change clients on XA QueryEvents and XA_PREPARE_LOG_EVENT (including within binlog_transaction_compression payloads).
  • Update GTID client query-event processing to return fatal errors to the stream reader (instead of silently continuing).
  • Add/adjust end-to-end and synthetic-stream tests ensuring the abort happens before buffering/applying any XA row events and that resume coordinates don’t advance past refused groups.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pkg/change/utils.go Adds shared XA guard error and helper used by both change clients.
pkg/change/gtid.go GTID client: fail stream on XA QueryEvents / XA prepare event (incl. compressed payload path) and plumb fatal errors up to the reader loop.
pkg/change/gtid_test.go Expands GTID tests to assert fail-fast behavior, empty buffers, preserved checkpoint semantics, and compressed-stream coverage.
pkg/change/binlog.go Binlog (file/offset) client: add the same XA guards in both uncompressed and compressed event paths.
pkg/change/binlog_test.go Adds binlog client tests mirroring GTID coverage for fail-fast XA handling and compressed payload guarding.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/change/utils.go
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.

2 participants