Skip to content

Commit e584bca

Browse files
authored
fix(mysql): handle binlog incident event (resync required) (#4435)
## Summary Handle MySQL binlog `INCIDENT_EVENT` (e.g. `LOST_EVENTS`) in the CDC pull loop. When the source emits an incident event, binlog events were lost from the stream and our CDC position can no longer be trusted — so we fail with a resync-required error rather than silently skipping the gap. - Detect `INCIDENT_EVENT` (decoded by go-mysql as `GenericEvent`, gated on the header event type) in `PullRecords` and return a new `MySQLBinlogIncidentError`. - Add `parseIncidentEvent` to extract the incident number and message from the event body. - Classify `MySQLBinlogIncidentError` as `ErrorNotifyBinlogInvalid` / `BINLOG_INCIDENT` in the alerting classifier. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent d1c9d2d commit e584bca

4 files changed

Lines changed: 58 additions & 0 deletions

File tree

flow/alerting/classifier.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,13 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) {
11321132
}
11331133
}
11341134

1135+
if _, ok := errors.AsType[*exceptions.MySQLBinlogIncidentError](err); ok {
1136+
return ErrorNotifyBinlogInvalid, ErrorInfo{
1137+
Source: ErrorSourceMySQL,
1138+
Code: "BINLOG_INCIDENT",
1139+
}
1140+
}
1141+
11351142
if mysqlGeometryParseError, ok := errors.AsType[*exceptions.MySQLGeometryParseError](err); ok &&
11361143
strings.Contains(mysqlGeometryParseError.Error(), mysqlGeometryLinearRingNotClosedError) {
11371144
return ErrorUnsupportedDatatype, ErrorInfo{

flow/alerting/classifier_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,3 +1202,15 @@ func TestUnwrappedGoogleAPIErrorShouldNotBeBigQuery(t *testing.T) {
12021202
Code: "UNKNOWN",
12031203
}, errInfo)
12041204
}
1205+
1206+
func TestMySQLBinlogIncidentErrorShouldBeNotifyBinlogInvalid(t *testing.T) {
1207+
t.Parallel()
1208+
1209+
err := exceptions.NewMySQLBinlogIncidentError(1, "LOST_EVENTS")
1210+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("pulling records failed: %w", err))
1211+
assert.Equal(t, ErrorNotifyBinlogInvalid, errorClass)
1212+
assert.Equal(t, ErrorInfo{
1213+
Source: ErrorSourceMySQL,
1214+
Code: "BINLOG_INCIDENT",
1215+
}, errInfo)
1216+
}

flow/connectors/mysql/cdc.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"cmp"
55
"context"
66
"crypto/tls"
7+
"encoding/binary"
78
"errors"
89
"fmt"
910
"log/slog"
@@ -551,6 +552,14 @@ func (c *MySqlConnector) PullRecords(
551552
req.RecordStream.UpdateLatestCheckpointText(updatedOffset)
552553
c.logger.Info("rotate", slog.String("name", pos.Name), slog.Uint64("pos", uint64(pos.Pos)))
553554
}
555+
case *replication.GenericEvent:
556+
// INCIDENT_EVENT (LOST_EVENTS) - fail and require resync
557+
if event.Header.EventType == replication.INCIDENT_EVENT {
558+
incident, message := parseIncidentEvent(ev.Data)
559+
c.logger.Error("[mysql] received binlog incident event, resync required",
560+
slog.Uint64("incident", uint64(incident)), slog.String("message", message))
561+
return exceptions.NewMySQLBinlogIncidentError(incident, message)
562+
}
554563
case *replication.QueryEvent:
555564
if !inTx && gset == nil && event.Header.LogPos > pos.Pos {
556565
pos.Pos = event.Header.LogPos
@@ -872,6 +881,20 @@ func posToOffsetText(pos mysql.Position) string {
872881
return fmt.Sprintf("!f:%s,%x", pos.Name, pos.Pos)
873882
}
874883

884+
// parseIncidentEvent extracts the incident number and human-readable message.
885+
// Best-effort: returns what it can if the body is truncated.
886+
func parseIncidentEvent(data []byte) (uint16, string) {
887+
if len(data) < 2 {
888+
return 0, ""
889+
}
890+
incident := binary.LittleEndian.Uint16(data[:2])
891+
if len(data) < 3 {
892+
return incident, ""
893+
}
894+
end := min(3+int(data[2]), len(data))
895+
return incident, string(data[3:end])
896+
}
897+
875898
// processTableMapEventSchema compares the TABLE_MAP_EVENT schema against the cached schema
876899
// and returns a TableSchemaDelta if new columns are detected (e.g., after gh-ost migration).
877900
// It also returns a slice mapping binlog column index to FieldDescription for efficient row processing.

flow/shared/exceptions/mysql.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,19 @@ func (e *MySQLStaleConnectionError) Error() string {
137137
return fmt.Sprintf("MySQL connection is stale: no events received in %v (heartbeat=%v)",
138138
e.Since, e.HeartbeatPeriod)
139139
}
140+
141+
type MySQLBinlogIncidentError struct {
142+
Message string
143+
Incident uint16
144+
}
145+
146+
func NewMySQLBinlogIncidentError(incident uint16, message string) *MySQLBinlogIncidentError {
147+
return &MySQLBinlogIncidentError{Incident: incident, Message: message}
148+
}
149+
150+
func (e *MySQLBinlogIncidentError) Error() string {
151+
if e.Message != "" {
152+
return fmt.Sprintf("MySQL binlog incident event received (incident=%d): %s; a resync is required", e.Incident, e.Message)
153+
}
154+
return fmt.Sprintf("MySQL binlog incident event received (incident=%d); a resync is required", e.Incident)
155+
}

0 commit comments

Comments
 (0)