fix(change): refuse a wrapped LogPos and unknown rows-event subtypes - #1039
Conversation
Two ways the binlog reader could lose row changes without saying so. LogPos wraparound. A binlog event header's end position is a 4-byte field, and a binlog file only rotates on a transaction boundary — so one transaction writing more than 4GiB of row images past the max_binlog_size point grows the file beyond what a uint32 can address, and positions restart near zero. MySQL documents the consequence under max_binlog_cache_size: with gtid_mode off, "the maximum recommended value is 4GB, because in that case MySQL cannot work with binary log positions greater than 4GB". Past the wrap, setBufferedPos's monotonicity freezes bufferedPos just under the wrap point and shouldSkipReplayedEvent classifies every later row event as a post-reconnect replay and discards it; the checkpoint stops advancing at the same moment. readStream now watches for the wrap directly: within a file an event's end position only ever increases, so a backwards step is the signature of the field wrapping, and the check resets on every rotate — which is also what keeps recreateStreamer's replay from position 4 from looking like one. Positions the server synthesized rather than read from the file (LogPos=0, artificial events, heartbeats) are excluded. On detection the stream fails with the new FatalReasonLogPosWrapped, which invalidates the caller's checkpoint: resuming from it would stream back into the same oversized transaction and wrap again. Only the binlog client is affected — the GTID client does not deduplicate by position, which is why MySQL's own caveat is scoped to gtid_mode being off. Unknown rows-event subtypes. go-mysql parses several rows-event subtypes this package does not recognize into a plain *replication.RowsEvent. PARTIAL_UPDATE_ROWS_EVENT is the live case: the server emits it once binlog_row_value_options=PARTIAL_JSON is set, and the global can change after the preflight check has read it. Such an event reached processRowsEvent, fell into the default branch and had its rows dropped with only logger.Error — asymmetric with the minimal-row-image case next to it, which hard-fails. Both clients now fail the stream on an unrecognized subtype, naming the type (string form and header byte) and the table. The insert/delete loop's default branch becomes a hard error too, so a future eventType addition cannot reintroduce a silent drop. Events for unsubscribed tables stay ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a746784 to
2f353d2
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Checkpoint invalidation and zero-position wraparound handling have unresolved review findings.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
Hardens binlog handling against LogPos wraparound and unsupported row-event subtypes to prevent silent data loss.
Changes:
- Detects and rejects wrapped 32-bit binlog positions.
- Fails on unsupported subscribed-table row-event subtypes.
- Adds fatal-reason handling and focused tests.
| File | Reviewed changes |
|---|---|
pkg/move/runner.go |
Invalidates checkpoints on wrapped positions. |
pkg/migration/runner.go |
Invalidates checkpoints on wrapped positions. |
pkg/change/utils.go |
Adds position tracking and fatal-reason mapping. |
pkg/change/utils_test.go |
Tests position tracking and reason mapping. |
pkg/change/gtid.go |
Rejects unsupported row-event subtypes. |
pkg/change/gtid_test.go |
Tests GTID subtype handling. |
pkg/change/event_type.go |
Adds unsupported-event error handling. |
pkg/change/config.go |
Defines the wrapped-position fatal reason. |
pkg/change/binlog.go |
Detects position wraparound and rejects unknown subtypes. |
pkg/change/binlog_test.go |
Tests wraparound and subtype handling. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
An event ending exactly on 2^32 reports LogPos=0 — the one wrapped offset that collides with "this event has no position". The tracker read every zero as positionless, so such an event was skipped by the replay guard and, if the file rotated before the next real position arrived, the rotate reset the tracker with the wrap never reported. Zero is now read as a backwards step for the two event types that carry row changes (RowsEvent, TransactionPayloadEvent) once a real position has been seen in the file. Housekeeping events at zero stay ignored: a dropped one costs nothing, while a dropped RowsEvent is the data loss this guard exists to prevent. The artificial and heartbeat filters move above the zero check so they still take precedence. Also names the new reason in datasync's fatalError contract, and spells out that its deliberate keep-the-checkpoint policy means a caller restarting after XA or a wrapped LogPos must start fresh, not resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Adversarial correctness review of 0 blocking, 1 non-blocking. The LogPos tracker holds up. Each of these mutations is caught by a test in this PR:
Only the outer header of a compressed payload feeds the tracker ( Removing the Non-blocking1. An unsupported rows event keeps the checkpoint and tells the operator to resume, and a resume replays the same event. Both rows paths hardcode the checkpoint-preserving reason instead of going through The checkpoint can't be past that event, since the event was never applied. Resetting That is the same reasoning the runner comment gives for invalidating on XA and a wrapped LogPos, "resuming from it streams straight back into the condition that killed the run". The new datasync doc at The test's own comment chooses this deliberately ( The smallest fix I tried:
Test that fails on
|
| Build | Test above | TestBinlogProcessRowsEventUnknownSubtype (PR) |
|---|---|---|
8f031d1 |
--- FAIL: Should not be: 1 (FatalReasonStreamError) |
PASS |
both rows paths via fatalReasonForStreamError, errUnsupportedRowsEvent mapped to an invalidating reason |
PASS | --- FAIL at :1854, as expected, since it pins the current choice |
The GTID path at gtid.go:687 has the same shape. I didn't write a separate GTID test.
This review was generated by Claude Code (claude-opus-5-5).
aparajon
left a comment
There was a problem hiding this comment.
🤖 Stamped: 0 blocking, 1 non-blocking. See the review comment above.
This stamp was left by Claude Code (claude-opus-5-5).


Two robustness fixes in
pkg/changebinlog event handling, both in the "silent data loss" class: row changes that arrive on the stream and never make it into the buffer.Bug A: a wrapped LogPos makes the replay-skip guard discard live row events
Problem. A binlog event header's end position is a 4-byte field, and a binlog file only rotates on a transaction boundary — so one transaction writing more than 4GiB of row images past the
max_binlog_sizepoint grows the file beyond what a uint32 can address, and positions restart near zero. MySQL documents the consequence undermax_binlog_cache_size: withgtid_modeoff, "the maximum recommended value is 4GB, because in that case MySQL cannot work with binary log positions greater than 4GB".max_binlog_cache_sizedefaults to 16EB on 64-bit, so nothing stops a single transaction from doing this.Past the wrap,
setBufferedPos's monotonicity freezesbufferedPosjust under the wrap point, every laterRowsEventcompares at or below it, andshouldSkipReplayedEventclassifies them all as post-reconnect replays and discards them. The checkpoint stops advancing at the same moment. The lost changes were only caught by the checksum, where enabled.Scope. Only the binlog (file + position) client. The GTID client does not deduplicate by position and has no
bufferedPos— which is exactly why MySQL's own caveat is scoped togtid_modebeing off. Since #1139 auto-detects GTIDs, this bites servers that have them disabled.Fix.
readStreamdetects the wrap directly rather than inferring it. Within a binlog file an event's end position only ever increases, so a backwards step is the signature of the field wrapping. The detector resets on everyRotateEvent— which is also what keepsrecreateStreamer's replay from position 4 from looking like a wrap, since the server prefaces every dump with an artificial rotate. Positions the server synthesized rather than read from the file are excluded:LogPos == 0, events flaggedLOG_EVENT_ARTIFICIAL_F, and heartbeats (whose position can name a file the reader has not rotated into yet).On detection the stream fails with a new
FatalReasonLogPosWrapped. That reason invalidates the caller's checkpoint, alongsideFatalReasonUnsupportedXA: the checkpointed position is technically readable, but resuming from it streams straight back into the same oversized transaction and wraps again, so a resume can never make progress. A fresh run started once the file has rotated gets usable coordinates.Design note. An earlier revision of this PR tried to keep the migration running by classifying wrapped events heuristically (a gap of more than 2^31 below
bufferedPos, suppressed inside a post-reconnect replay window) and re-delivering them. That was dropped for two reasons. It needed a threshold and areplayingstate machine in the hot path to approximate something the rotate reset answers exactly, and it was only a half-fix: past the wrapbufferedPos/flushedPoscannot advance until the next rotation, so the run would continue with a checkpoint that silently stopped tracking reality. Refusing is the same posture as #1079's XA refusal — do not apply what cannot be tracked correctly.Residual, documented in code. A run that starts at an already-wrapped position (a fresh run against a server currently mid-oversized-transaction) reads from a garbage offset; that is a property of 32-bit coordinates, not something this guard can see. The recommendation in the error message is to enable GTIDs, or lower
max_binlog_cache_sizeso no transaction can grow a file past 4GiB.Bug B: unknown rows-event subtypes were dropped with only an error log
Problem. go-mysql parses more rows-event subtypes into a plain
*replication.RowsEventthan this package knows how to apply —parser.go:323routesPARTIAL_UPDATE_ROWS_EVENTthere alongside the recognized ones. The server emits it oncebinlog_row_value_options=PARTIAL_JSONis set, and preflight reads that global once at start, so it can change afterwards. Such an event reachedprocessRowsEvent(both clients), fell into thedefault:branch, and had its rows skipped with onlylogger.Error("unknown event type")— nofatalError(). That is asymmetric with the minimal-row-image check immediately above it, which hard-fails.Fix. An unrecognized rows-event subtype for a subscribed table is now a hard error from
processRowsEventin both clients, naming the type both ways (PartialUpdateRowsEvent (0x27)— an event type new enough that go-mysql has no name for it renders as "Unknown", and the header byte is then the only identifier in a log line) and the table. It flows through the existing fatal path exactly like the minimal-row-image case. The insert/delete loop'sdefault:branch becomes a hard error too, so a futureeventTypeaddition cannot reintroduce a silent drop. Events for unsubscribed tables (e.g._newtables) are still ignored.Testing
TestLogPosTracker— the detector in isolation: ascending positions and repeated positions are clean, a backwards step reports a wrap and does not advance the tracker, a rotate reset makes a replay from position 4 clean, and positionless/artificial/heartbeat events are ignored.TestBinlogClientLogPosWraparoundGuard— drives the realreadStreamthrough an injected streamer: pre-wrap event buffers, post-wrap event reportsFatalReasonLogPosWrapped, nothing past the wrap is buffered, and the position does not advance into wrapped space.TestBinlogClientReplayIsNotAWraparound— the false-positive guard: a high-water mark, an artificial rotate back to the same file, replayed low positions, then a genuinely new event. Must buffer and must not report a wrap.TestBinlogProcessRowsEventUnknownSubtype/TestGTIDProcessRowsEventUnknownSubtype— a syntheticPARTIAL_UPDATE_ROWS_EVENTreturns an error matchingerrUnsupportedRowsEventand naming the type and table, buffers nothing; a recognized subtype still buffers; unsubscribed tables stay ignored.TestFatalReasonForStreamError— pins the error-to-reason mapping.Each new test was verified red against the pre-fix behavior by fault injection (guards reverted to their previous form,
go buildconfirmed, sources restored andcmp-verified byte-identical afterwards).go build,gofmt,go vet,golangci-lint run ./pkg/change/... ./pkg/migration/... ./pkg/move/...(0 issues). Full./pkg/changesuite against MySQL 8.0.45, plus the new tests under-race -count=3, and theFatal|XA|Checkpointsubsets of./pkg/migration ./pkg/move ./pkg/datasync.🤖 Generated with Claude Code