Skip to content

Commit 65c76c7

Browse files
dtunikovclaude
andauthored
fix(mysql): pull INVISIBLE/GIPK columns during snapshot (#4553)
## Problem MySQL/MariaDB QRep snapshot took a `SELECT *` fast path in `buildSelectedColumns`. Wildcard expansion **omits INVISIBLE columns** — including the generated invisible primary key (`my_row_id`) from `sql_generate_invisible_primary_key`. But the destination table (created from `information_schema.columns`) and CDC (binlog row images) both **include** those columns. Result: snapshot rows keep destination defaults forever while CDC populates real values — silent divergence. Worst case is GIPK, where the invisible `my_row_id` is the table's only PK, so snapshot rows arrive with no key, breaking dedup/ordering. ## Fix Always build an explicit column list from the fetched table schema (drop the `SELECT *` fast path). The schema already contains invisible/GIPK columns, and naming them explicitly bypasses wildcard invisibility, so the snapshot pulls them — matching CDC and the destination DDL. ## Tests - Unit: `buildSelectedColumns` never returns `*`; invisible/GIPK columns are listed explicitly. - E2E (`clickhouse_mysql_test.go`): `Test_MySQL_Invisible_Column_Consistency` and `Test_MySQL_GIPK_Consistency` compare snapshot vs CDC values (version-gated; GIPK is MySQL-only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fd0b39c commit 65c76c7

4 files changed

Lines changed: 158 additions & 12 deletions

File tree

flow/connectors/mysql/qrep.go

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,28 +244,20 @@ func (c *MySqlConnector) GetDefaultPartitionKeyForTables(
244244
}
245245

246246
func buildSelectedColumns(cols []*protos.FieldDescription, exclude []string) string {
247-
columns := []string{}
248-
selectAsterisk := true
247+
columns := make([]string, 0, len(cols))
249248
for _, col := range cols {
250249
if slices.Contains(exclude, col.Name) {
251-
selectAsterisk = false
252250
continue
253251
}
254252

255253
converted := common.QuoteMySQLIdentifier(col.Name)
256254
if col.Type == string(types.QValueKindUint16Enum) || col.Type == string(types.QValueKindUint64Set) {
257255
converted = fmt.Sprintf("CAST(%s AS UNSIGNED) AS %s", converted, converted)
258-
selectAsterisk = false
259256
}
260257
columns = append(columns, converted)
261258
}
262259

263-
selectedColumns := "*"
264-
if !selectAsterisk {
265-
selectedColumns = strings.Join(columns, ", ")
266-
}
267-
268-
return selectedColumns
260+
return strings.Join(columns, ", ")
269261
}
270262

271263
func (c *MySqlConnector) PullQRepRecords(
@@ -285,6 +277,10 @@ func (c *MySqlConnector) PullQRepRecords(
285277
}
286278

287279
selectedColumns := buildSelectedColumns(tableSchema.Columns, config.Exclude)
280+
if selectedColumns == "" {
281+
return 0, 0, fmt.Errorf("no columns selected for watermark table %s (check Exclude configuration)", config.WatermarkTable)
282+
}
283+
288284
parsedSrcTable, err := common.ParseTableIdentifier(config.WatermarkTable)
289285
if err != nil {
290286
c.logger.Error("unable to parse source table", slog.Any("error", err))

flow/connectors/mysql/qrep_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestBuildSelectedColumns(t *testing.T) {
2727
{Name: "status", Type: string(types.QValueKindEnum)},
2828
},
2929
exclude: []string{},
30-
expectedSelectedColumns: "*",
30+
expectedSelectedColumns: "`id`, `name`, `status`",
3131
},
3232
{
3333
name: "one excluded column",
@@ -73,7 +73,18 @@ func TestBuildSelectedColumns(t *testing.T) {
7373
{Name: "status", Type: string(types.QValueKindEnum)},
7474
},
7575
exclude: []string{},
76-
expectedSelectedColumns: "*",
76+
expectedSelectedColumns: "`id`, `status`",
77+
},
78+
{
79+
// INVISIBLE / GIPK columns are present in the fetched schema but omitted by
80+
// wildcard expansion; they must appear explicitly so snapshot pulls them.
81+
name: "invisible/gipk column is listed explicitly",
82+
cols: []*protos.FieldDescription{
83+
{Name: "my_row_id", Type: string(types.QValueKindUInt64)},
84+
{Name: "name", Type: string(types.QValueKindString)},
85+
},
86+
exclude: []string{},
87+
expectedSelectedColumns: "`my_row_id`, `name`",
7788
},
7889
{
7990
name: "uint16enum with exclude",

flow/e2e/clickhouse_mysql_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2551,3 +2551,139 @@ func (s ClickHouseSuite) Test_MySQL_NoPrimaryKey_CDC() {
25512551
env.Cancel(s.t.Context())
25522552
RequireEnvCanceled(s.t, env)
25532553
}
2554+
2555+
func (s ClickHouseSuite) Test_MySQL_Invisible_Column_Consistency() {
2556+
mySource, ok := s.source.(*MySqlSource)
2557+
if !ok {
2558+
s.t.Skip("only applies to mysql")
2559+
}
2560+
2561+
minVersion := mysql_validation.MySQLMinVersionForInvisibleColumns
2562+
if mySource.Config.Flavor == protos.MySqlFlavor_MYSQL_MARIA {
2563+
minVersion = mysql_validation.MariaDBMinVersionForInvisibleColumns
2564+
}
2565+
cmp, err := mySource.CompareServerVersion(s.t.Context(), minVersion)
2566+
require.NoError(s.t, err)
2567+
if cmp < 0 {
2568+
s.t.Skip("server version does not support invisible columns")
2569+
}
2570+
2571+
srcTableName := "test_my_invisible"
2572+
srcFullName := s.attachSchemaSuffix(srcTableName)
2573+
dstTableName := "test_my_invisible_dst"
2574+
2575+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(`
2576+
CREATE TABLE IF NOT EXISTS %s (
2577+
id INT PRIMARY KEY,
2578+
name VARCHAR(50) NOT NULL,
2579+
secret INT NOT NULL DEFAULT 0 INVISIBLE
2580+
)
2581+
`, srcFullName)))
2582+
2583+
// Pre-snapshot row with an explicit, non-default invisible value.
2584+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(
2585+
`INSERT INTO %s (id, name, secret) VALUES (1, 'snap', 111)`, srcFullName)))
2586+
2587+
connectionGen := FlowConnectionGenerationConfig{
2588+
FlowJobName: s.attachSuffix(srcTableName),
2589+
TableNameMapping: map[string]string{srcFullName: dstTableName},
2590+
Destination: s.Peer().Name,
2591+
}
2592+
flowConnConfig := connectionGen.GenerateFlowConnectionConfigs(s)
2593+
flowConnConfig.DoInitialSnapshot = true
2594+
2595+
tc := NewTemporalClient(s.t)
2596+
env := ExecutePeerflow(s.t, tc, flowConnConfig)
2597+
SetupCDCFlowStatusQuery(s.t, env, flowConnConfig)
2598+
2599+
EnvWaitForCount(env, s, "waiting on snapshot", dstTableName, "id,name,secret", 1)
2600+
2601+
// Post-snapshot CDC row with a distinct invisible value.
2602+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(
2603+
`INSERT INTO %s (id, name, secret) VALUES (2, 'cdc', 222)`, srcFullName)))
2604+
2605+
EnvWaitForCount(env, s, "waiting on cdc", dstTableName, "id,name,secret", 2)
2606+
2607+
rows, err := s.GetRows(dstTableName, "id,name,secret")
2608+
require.NoError(s.t, err)
2609+
require.Len(s.t, rows.Records, 2)
2610+
// Snapshot row must carry the real invisible value, not the destination default.
2611+
require.EqualValues(s.t, 111, rows.Records[0][2].Value(), "snapshot invisible column should be pulled")
2612+
require.EqualValues(s.t, 222, rows.Records[1][2].Value(), "cdc invisible column should be pulled")
2613+
2614+
env.Cancel(s.t.Context())
2615+
RequireEnvCanceled(s.t, env)
2616+
}
2617+
2618+
func (s ClickHouseSuite) Test_MySQL_GIPK_Consistency() {
2619+
mySource, ok := s.source.(*MySqlSource)
2620+
if !ok {
2621+
s.t.Skip("only applies to mysql")
2622+
}
2623+
if mySource.Config.Flavor == protos.MySqlFlavor_MYSQL_MARIA {
2624+
s.t.Skip("GIPK is not supported by MariaDB")
2625+
}
2626+
cmp, err := mySource.CompareServerVersion(s.t.Context(), mysql_validation.MySQLMinVersionForGeneratedInvisiblePrimaryKey)
2627+
require.NoError(s.t, err)
2628+
if cmp < 0 {
2629+
s.t.Skip("server version does not support generated invisible primary key")
2630+
}
2631+
2632+
srcTableName := "test_my_gipk"
2633+
srcFullName := s.attachSchemaSuffix(srcTableName)
2634+
dstTableName := "test_my_gipk_dst"
2635+
2636+
require.NoError(s.t, s.source.Exec(s.t.Context(), `SET SESSION sql_generate_invisible_primary_key = ON`))
2637+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(`
2638+
CREATE TABLE IF NOT EXISTS %s (
2639+
name VARCHAR(50) NOT NULL
2640+
)
2641+
`, srcFullName)))
2642+
require.NoError(s.t, s.source.Exec(s.t.Context(), `SET SESSION sql_generate_invisible_primary_key = OFF`))
2643+
2644+
// Pre-snapshot row: my_row_id auto-assigns 1.
2645+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(
2646+
`INSERT INTO %s (name) VALUES ('snap')`, srcFullName)))
2647+
2648+
// The default TableNameMapping path shards on "id", which this table lacks
2649+
// (its only key is the invisible my_row_id), so shard on the GIPK for the cluster suite.
2650+
var shardingKey string
2651+
if s.cluster {
2652+
shardingKey = "my_row_id"
2653+
}
2654+
connectionGen := FlowConnectionGenerationConfig{
2655+
FlowJobName: s.attachSuffix(srcTableName),
2656+
TableMappings: []*protos.TableMapping{{
2657+
SourceTableIdentifier: srcFullName,
2658+
DestinationTableIdentifier: dstTableName,
2659+
ShardingKey: shardingKey,
2660+
}},
2661+
Destination: s.Peer().Name,
2662+
}
2663+
flowConnConfig := connectionGen.GenerateFlowConnectionConfigs(s)
2664+
flowConnConfig.DoInitialSnapshot = true
2665+
2666+
tc := NewTemporalClient(s.t)
2667+
env := ExecutePeerflow(s.t, tc, flowConnConfig)
2668+
SetupCDCFlowStatusQuery(s.t, env, flowConnConfig)
2669+
2670+
EnvWaitForCount(env, s, "waiting on snapshot", dstTableName, "my_row_id,name", 1)
2671+
2672+
// Post-snapshot CDC row: my_row_id auto-assigns 2.
2673+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(
2674+
`INSERT INTO %s (name) VALUES ('cdc')`, srcFullName)))
2675+
2676+
EnvWaitForCount(env, s, "waiting on cdc", dstTableName, "my_row_id,name", 2)
2677+
2678+
rows, err := s.GetRows(dstTableName, "my_row_id,name")
2679+
require.NoError(s.t, err)
2680+
require.Len(s.t, rows.Records, 2)
2681+
// The snapshot row's my_row_id must be the real key value, not the destination default.
2682+
require.EqualValues(s.t, 1, rows.Records[0][0].Value(), "snapshot GIPK should be pulled")
2683+
require.Equal(s.t, "snap", rows.Records[0][1].Value())
2684+
require.EqualValues(s.t, 2, rows.Records[1][0].Value(), "cdc GIPK should be pulled")
2685+
require.Equal(s.t, "cdc", rows.Records[1][1].Value())
2686+
2687+
env.Cancel(s.t.Context())
2688+
RequireEnvCanceled(s.t, env)
2689+
}

flow/pkg/mysql/validation.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const (
1616
MySQLMinVersionForBinlogRowMetadata = "8.0.1"
1717
MariaDBMinVersionForBinlogRowMetadata = "10.5.0"
1818
MySQLMinVersionForBinlogTransactionCompression = "8.0.20"
19+
MySQLMinVersionForInvisibleColumns = "8.0.23"
20+
MariaDBMinVersionForInvisibleColumns = "10.3.0"
21+
MySQLMinVersionForGeneratedInvisiblePrimaryKey = "8.0.30"
1922
)
2023

2124
func CompareServerVersion(conn *client.Conn, version string) (int, error) {

0 commit comments

Comments
 (0)