Skip to content

Commit cd593d9

Browse files
dtunikovclaudeCopilot
authored
mysql: transcode non-utf8 string/ENUM/SET columns to UTF-8 on CDC path (#4436)
## Problem Linear: [DBI-810](https://linear.app/clickhouse/issue/DBI-810/b2-cdc-path-performs-no-character-set-transcoding-latin1gbksjis-text) Binlog row events carry string/ENUM/SET data as **raw bytes in each column's own character set**. Unlike the snapshot path — which runs `SET NAMES utf8mb4` so the server transcodes before sending — the CDC path got the bytes verbatim and cast them straight to Go strings (`qvalue_convert.go`). Result: for any non-utf8 column (`latin1` was the MySQL server default through 5.7, plus `gbk`/`sjis`/etc.), every non-ASCII character reached the destination as **invalid-UTF-8 mojibake on CDC while the snapshot was correct** — the same source value landing in two different encodings. ENUM/SET labels were equally raw. The collation needed to fix this was already present in `TABLE_MAP` metadata but unused for decoding. ## Fix - Resolve an `x/text` decoder per column from the `TABLE_MAP` collation metadata (`CollationMap()` for character columns, `EnumSetCollationMap()` for enum/set). - Transcode string values **and** ENUM/SET label tables to UTF-8. - `utf8`/`utf8mb3`/`utf8mb4`/`ascii`/`binary` resolve to a nil decoder → original **zero-cost passthrough**, so the common case is unaffected. - Collation id → charset name is sourced from `information_schema.COLLATIONS` and cached on the connector (covers MySQL + MariaDB without a hardcoded ~300-entry table). - Graceful degradation: unknown collation / unsupported charset / missing binlog row metadata → **warn once and pass bytes through** (no worse than today), rather than halting the mirror. Note: MySQL's `latin1` is Windows-1252 (not ISO-8859-1) — handled explicitly. ## Tests - `charset_test.go`: unit tests for the transcoders (latin1/gbk/sjis/euckr + passthrough), and a before/after assertion that a latin1 value was mojibake and is now correctly decoded. - `Test_MySQL_Charset_Consistency` (e2e): latin1 + gbk columns, asserts snapshot and CDC rows are **byte-identical** and correctly decoded. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 9c9f409 commit cd593d9

8 files changed

Lines changed: 403 additions & 10 deletions

File tree

flow/connectors/mysql/cdc.go

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
_ "github.com/pingcap/tidb/pkg/types/parser_driver"
2222
"go.opentelemetry.io/otel/attribute"
2323
"go.opentelemetry.io/otel/trace"
24+
"golang.org/x/text/encoding"
2425
"google.golang.org/protobuf/proto"
2526

2627
"github.com/PeerDB-io/peerdb/flow/connectors/utils"
@@ -606,6 +607,55 @@ func (c *MySqlConnector) PullRecords(
606607
enumMap := ev.Table.EnumStrValueMap()
607608
setMap := ev.Table.SetStrValueMap()
608609

610+
// build colIdx -> encoding map based on event collation map.
611+
// Allocated lazily: tables whose character columns are all utf8/ascii/binary
612+
// (the common case) resolve every collation to a nil encoding and never allocate.
613+
var colEncodings []encoding.Encoding
614+
encFor := func(idx int) encoding.Encoding {
615+
if idx >= 0 && idx < len(colEncodings) {
616+
return colEncodings[idx]
617+
}
618+
return nil
619+
}
620+
setColEncoding := func(colIdx int, enc encoding.Encoding) {
621+
if colEncodings == nil {
622+
colEncodings = make([]encoding.Encoding, len(ev.Table.ColumnType))
623+
}
624+
colEncodings[colIdx] = enc
625+
}
626+
for colIdx, collationID := range ev.Table.CollationMap() {
627+
if colIdx < 0 || colIdx >= len(ev.Table.ColumnType) {
628+
continue
629+
}
630+
enc, err := c.collationEncoding(ctx, collationID, otelManager)
631+
if err != nil {
632+
return err
633+
}
634+
if enc == nil {
635+
continue
636+
}
637+
setColEncoding(colIdx, enc)
638+
}
639+
for colIdx, collationID := range ev.Table.EnumSetCollationMap() {
640+
enc, err := c.collationEncoding(ctx, collationID, otelManager)
641+
if err != nil {
642+
return err
643+
}
644+
if enc == nil {
645+
continue
646+
}
647+
setColEncoding(colIdx, enc)
648+
for labels := range slices.Values([][]string{enumMap[colIdx], setMap[colIdx]}) {
649+
for i, label := range labels {
650+
decoded, err := decodeMySQLString(enc, label)
651+
if err != nil {
652+
return err
653+
}
654+
labels[i] = decoded
655+
}
656+
}
657+
}
658+
609659
// Process TABLE_MAP_EVENT schema to detect new columns
610660
var fields []*protos.FieldDescription
611661
if ev.Table.ColumnName != nil {
@@ -645,7 +695,7 @@ func (c *MySqlConnector) PullRecords(
645695
continue
646696
}
647697
val, err := QValueFromMysqlRowEvent(ev.Table, idx, enumMap[idx], setMap[idx],
648-
types.QValueKind(fd.Type), val, c.logger, &coercionReported)
698+
types.QValueKind(fd.Type), val, encFor(idx), c.logger, &coercionReported)
649699
if err != nil {
650700
return err
651701
}
@@ -682,7 +732,7 @@ func (c *MySqlConnector) PullRecords(
682732
continue
683733
}
684734
val, err := QValueFromMysqlRowEvent(ev.Table, idx, enumMap[idx], setMap[idx],
685-
types.QValueKind(fd.Type), val, c.logger, &coercionReported)
735+
types.QValueKind(fd.Type), val, encFor(idx), c.logger, &coercionReported)
686736
if err != nil {
687737
return err
688738
}
@@ -696,7 +746,7 @@ func (c *MySqlConnector) PullRecords(
696746
continue
697747
}
698748
val, err := QValueFromMysqlRowEvent(ev.Table, idx, enumMap[idx], setMap[idx],
699-
types.QValueKind(fd.Type), val, c.logger, &coercionReported)
749+
types.QValueKind(fd.Type), val, encFor(idx), c.logger, &coercionReported)
700750
if err != nil {
701751
return err
702752
}
@@ -734,7 +784,7 @@ func (c *MySqlConnector) PullRecords(
734784
continue
735785
}
736786
val, err := QValueFromMysqlRowEvent(ev.Table, idx, enumMap[idx], setMap[idx],
737-
types.QValueKind(fd.Type), val, c.logger, &coercionReported)
787+
types.QValueKind(fd.Type), val, encFor(idx), c.logger, &coercionReported)
738788
if err != nil {
739789
return err
740790
}

flow/connectors/mysql/charset.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package connmysql
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
8+
"go.opentelemetry.io/otel/attribute"
9+
"go.opentelemetry.io/otel/metric"
10+
"golang.org/x/text/encoding"
11+
"golang.org/x/text/encoding/charmap"
12+
"golang.org/x/text/encoding/japanese"
13+
"golang.org/x/text/encoding/korean"
14+
"golang.org/x/text/encoding/simplifiedchinese"
15+
"golang.org/x/text/encoding/traditionalchinese"
16+
"golang.org/x/text/encoding/unicode"
17+
"golang.org/x/text/encoding/unicode/utf32"
18+
"golang.org/x/text/transform"
19+
20+
"github.com/PeerDB-io/peerdb/flow/otel_metrics"
21+
)
22+
23+
// charsetsNoTranscode lists the MySQL character sets whose stored bytes are
24+
// already valid UTF-8 (or are opaque binary)
25+
var charsetsNoTranscode = map[string]struct{}{
26+
"utf8": {},
27+
"utf8mb3": {},
28+
"utf8mb4": {},
29+
"ascii": {},
30+
"binary": {},
31+
}
32+
33+
// mysqlCharsetEncodings maps a MySQL character set name to the golang.org/x/text
34+
var mysqlCharsetEncodings = map[string]encoding.Encoding{
35+
// Single-byte / Windows & ISO code pages.
36+
"latin1": charmap.Windows1252,
37+
"latin2": charmap.ISO8859_2,
38+
"latin5": charmap.ISO8859_9,
39+
"latin7": charmap.ISO8859_13,
40+
"cp1250": charmap.Windows1250,
41+
"cp1251": charmap.Windows1251,
42+
"cp1256": charmap.Windows1256,
43+
"cp1257": charmap.Windows1257,
44+
"cp850": charmap.CodePage850,
45+
"cp852": charmap.CodePage852,
46+
"cp866": charmap.CodePage866,
47+
"koi8r": charmap.KOI8R,
48+
"koi8u": charmap.KOI8U,
49+
"greek": charmap.ISO8859_7,
50+
"hebrew": charmap.ISO8859_8,
51+
"tis620": charmap.Windows874,
52+
"macroman": charmap.Macintosh,
53+
54+
// Multi-byte CJK code pages.
55+
"gbk": simplifiedchinese.GBK,
56+
"gb2312": simplifiedchinese.GBK, // GBK is a strict superset of GB2312/EUC-CN
57+
"gb18030": simplifiedchinese.GB18030,
58+
"big5": traditionalchinese.Big5,
59+
"sjis": japanese.ShiftJIS,
60+
"cp932": japanese.ShiftJIS, // cp932 is a near-superset of Shift-JIS
61+
"ujis": japanese.EUCJP,
62+
"eucjpms": japanese.EUCJP,
63+
"euckr": korean.EUCKR,
64+
65+
// Wide Unicode encodings.
66+
"utf16": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM),
67+
"utf16le": unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM),
68+
"ucs2": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM),
69+
"utf32": utf32.UTF32(utf32.BigEndian, utf32.IgnoreBOM),
70+
}
71+
72+
// collationEncoding resolves a MySQL collation id (as carried in binlog
73+
// TABLE_MAP metadata) to the x/text encoding needed to convert that column's
74+
// bytes to UTF-8.
75+
func (c *MySqlConnector) collationEncoding(
76+
ctx context.Context, collationID uint64, otelManager *otel_metrics.OtelManager,
77+
) (encoding.Encoding, error) {
78+
if collationID == 0 {
79+
return nil, nil
80+
}
81+
82+
recordUsedCharsetMetrics := func(ctx context.Context, charset string, status string) {
83+
otelManager.Metrics.UsedMySQLCharsetsCounter.Add(ctx, 1,
84+
metric.WithAttributeSet(attribute.NewSet(
85+
attribute.String("charset", charset),
86+
attribute.String("status", status),
87+
)),
88+
)
89+
}
90+
91+
charset, err := c.charsetForCollation(ctx, collationID)
92+
if err != nil {
93+
return nil, err
94+
}
95+
if charset == "" {
96+
c.warnCharsetOnce(fmt.Sprintf("collation:%d", collationID), func() {
97+
c.logger.Warn("unknown MySQL collation id on CDC path, passing bytes through untranscoded",
98+
slog.Uint64("collationID", collationID))
99+
})
100+
return nil, nil
101+
}
102+
103+
if _, skip := charsetsNoTranscode[charset]; skip {
104+
recordUsedCharsetMetrics(ctx, charset, "not_transcoded")
105+
return nil, nil
106+
}
107+
if enc, ok := mysqlCharsetEncodings[charset]; ok {
108+
recordUsedCharsetMetrics(ctx, charset, "transcoded")
109+
return enc, nil
110+
}
111+
112+
recordUsedCharsetMetrics(ctx, charset, "unsupported")
113+
c.warnCharsetOnce(charset, func() {
114+
c.logger.Warn("unsupported MySQL character set on CDC path, passing bytes through untranscoded",
115+
slog.String("charset", charset), slog.Uint64("collationID", collationID))
116+
})
117+
118+
return nil, nil
119+
}
120+
121+
func (c *MySqlConnector) charsetForCollation(ctx context.Context, collationID uint64) (string, error) {
122+
if m := c.collationCharset.Load(); m != nil {
123+
return (*m)[collationID], nil
124+
}
125+
126+
m, err := c.loadCollationCharsetMap(ctx)
127+
if err != nil {
128+
return "", err
129+
}
130+
c.collationCharset.Store(&m)
131+
return m[collationID], nil
132+
}
133+
134+
func (c *MySqlConnector) loadCollationCharsetMap(ctx context.Context) (map[uint64]string, error) {
135+
rs, err := c.Execute(ctx, "SELECT ID, CHARACTER_SET_NAME FROM information_schema.COLLATIONS")
136+
if err != nil {
137+
return nil, fmt.Errorf("failed to load collation charset map: %w", err)
138+
}
139+
140+
m := make(map[uint64]string, rs.RowNumber())
141+
for idx := range rs.RowNumber() {
142+
id, err := rs.GetInt(idx, 0)
143+
if err != nil {
144+
return nil, fmt.Errorf("failed to read collation id: %w", err)
145+
}
146+
charset, err := rs.GetString(idx, 1)
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to read collation charset name: %w", err)
149+
}
150+
m[uint64(id)] = charset
151+
}
152+
return m, nil
153+
}
154+
155+
func (c *MySqlConnector) warnCharsetOnce(key string, warn func()) {
156+
if _, loaded := c.warnedCharsets.LoadOrStore(key, struct{}{}); !loaded {
157+
warn()
158+
}
159+
}
160+
161+
// decodeMySQLBytes converts bytes stored in the column's character set to UTF-8.
162+
func decodeMySQLBytes(enc encoding.Encoding, b []byte) (string, error) {
163+
if enc == nil {
164+
return string(b), nil
165+
}
166+
out, _, err := transform.Bytes(enc.NewDecoder(), b)
167+
if err != nil {
168+
return "", fmt.Errorf("failed to transcode column bytes to UTF-8: %w", err)
169+
}
170+
return string(out), nil
171+
}
172+
173+
// decodeMySQLBytes converts string stored in the column's character set to UTF-8.
174+
func decodeMySQLString(enc encoding.Encoding, s string) (string, error) {
175+
if enc == nil {
176+
return s, nil
177+
}
178+
out, _, err := transform.String(enc.NewDecoder(), s)
179+
if err != nil {
180+
return "", fmt.Errorf("failed to transcode column string to UTF-8: %w", err)
181+
}
182+
return out, nil
183+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package connmysql
2+
3+
import (
4+
"testing"
5+
6+
"github.com/go-mysql-org/go-mysql/mysql"
7+
"github.com/go-mysql-org/go-mysql/replication"
8+
"github.com/stretchr/testify/require"
9+
"go.temporal.io/sdk/log"
10+
11+
"github.com/PeerDB-io/peerdb/flow/shared/types"
12+
)
13+
14+
func TestDecodeMySQLBytes(t *testing.T) {
15+
for _, tc := range []struct {
16+
name string
17+
charset string
18+
in []byte
19+
want string
20+
}{
21+
// MySQL latin1 is Windows-1252: 0x80 is the euro sign, 0xE9 is é.
22+
{"latin1 accented", "latin1", []byte{0x63, 0x61, 0x66, 0xE9}, "café"},
23+
{"latin1 euro", "latin1", []byte{0x80}, "€"},
24+
// gbk: 0xC4E3 0xBAC3 = 你好
25+
{"gbk hello", "gbk", []byte{0xC4, 0xE3, 0xBA, 0xC3}, "你好"},
26+
// sjis: 0x83 0x4F = グ
27+
{"sjis kana", "sjis", []byte{0x83, 0x4F}, "グ"},
28+
// euckr: 0xBEC8 0xB3E7 = 안녕
29+
{"euckr hangul", "euckr", []byte{0xBE, 0xC8, 0xB3, 0xE7}, "안녕"},
30+
// utf8mb4 passes through unchanged (nil encoding).
31+
{"utf8mb4 passthrough", "utf8mb4", []byte("café"), "café"},
32+
{"ascii passthrough", "ascii", []byte("plain"), "plain"},
33+
} {
34+
t.Run(tc.name, func(t *testing.T) {
35+
enc := mysqlCharsetEncodings[tc.charset] // nil for utf8mb4/ascii -> passthrough
36+
got, err := decodeMySQLBytes(enc, tc.in)
37+
require.NoError(t, err)
38+
require.Equal(t, tc.want, got)
39+
})
40+
}
41+
}
42+
43+
// TestQValueFromMysqlRowEvent_Transcodes verifies that the CDC decode path applies
44+
// the column's charset decoder
45+
func TestQValueFromMysqlRowEvent_Transcodes(t *testing.T) {
46+
ev := &replication.TableMapEvent{ColumnType: []byte{mysql.MYSQL_TYPE_VARCHAR}}
47+
logger := log.NewStructuredLogger(nil)
48+
var coercionReported bool
49+
50+
latin1 := []byte{0x63, 0x61, 0x66, 0xE9} // "café" in latin1
51+
52+
// Without a decoder (utf8mb4 column) the bytes are reinterpreted verbatim - mojibake.
53+
raw, err := QValueFromMysqlRowEvent(
54+
ev, 0, nil, nil, types.QValueKindString, latin1, nil, logger, &coercionReported)
55+
require.NoError(t, err)
56+
require.NotEqual(t, "café", raw.(types.QValueString).Val)
57+
58+
// With the latin1 decoder the value is correctly transcoded.
59+
decoded, err := QValueFromMysqlRowEvent(
60+
ev, 0, nil, nil, types.QValueKindString, latin1, mysqlCharsetEncodings["latin1"], logger, &coercionReported)
61+
require.NoError(t, err)
62+
require.Equal(t, "café", decoded.(types.QValueString).Val)
63+
}

flow/connectors/mysql/mysql.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log/slog"
1010
"net"
1111
"strings"
12+
"sync"
1213
"sync/atomic"
1314
"time"
1415

@@ -36,6 +37,8 @@ type MySqlConnector struct {
3637
logger log.Logger
3738
rdsAuth *utils.RDSAuth
3839
serverVersion string
40+
collationCharset atomic.Pointer[map[uint64]string]
41+
warnedCharsets sync.Map
3942
binlogHeartbeatPeriod time.Duration
4043
totalBytesRead atomic.Int64
4144
deltaBytesRead atomic.Int64

0 commit comments

Comments
 (0)