Skip to content

Commit 804a466

Browse files
authored
dm: reject FK causality hot config updates (#12703)
ref #12350
1 parent aff9f3d commit 804a466

4 files changed

Lines changed: 762 additions & 9 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
// Copyright 2026 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package syncer
15+
16+
import (
17+
"reflect"
18+
"sort"
19+
"strings"
20+
21+
"github.com/pingcap/tidb/pkg/util/filter"
22+
router "github.com/pingcap/tidb/pkg/util/table-router"
23+
"github.com/pingcap/tiflow/dm/config"
24+
"github.com/pingcap/tiflow/dm/pkg/terror"
25+
bf "github.com/pingcap/tiflow/pkg/binlog-filter"
26+
)
27+
28+
func (s *Syncer) checkForeignKeyCausalityConfigUpdate(newCfg *config.SubTaskConfig) error {
29+
oldNeedFKCausality := config.IsForeignKeyChecksEnabled(s.cfg.To.Session) && s.cfg.WorkerCount > 1
30+
newNeedFKCausality := config.IsForeignKeyChecksEnabled(newCfg.To.Session) && newCfg.WorkerCount > 1
31+
if !oldNeedFKCausality && !newNeedFKCausality {
32+
return nil
33+
}
34+
35+
if s.cfg.WorkerCount != newCfg.WorkerCount {
36+
return s.newForeignKeyCausalityConfigUpdateError("worker-count")
37+
}
38+
if s.cfg.CaseSensitive != newCfg.CaseSensitive {
39+
return s.newForeignKeyCausalityConfigUpdateError("case-sensitive")
40+
}
41+
// Compare route/filter semantics through normalized snapshots instead of
42+
// runtime objects that may contain compiled regex or cache fields.
43+
if !sameRouteRules(s.cfg.CaseSensitive, s.cfg.RouteRules, newCfg.RouteRules) {
44+
return s.newForeignKeyCausalityConfigUpdateError("route rules")
45+
}
46+
if !sameTableFilterRules(s.cfg.CaseSensitive, s.cfg.BAList, newCfg.BAList) {
47+
return s.newForeignKeyCausalityConfigUpdateError("block-allow-list")
48+
}
49+
if !sameTableFilterRules(s.cfg.CaseSensitive, s.cfg.BWList, newCfg.BWList) {
50+
return s.newForeignKeyCausalityConfigUpdateError("black-white-list")
51+
}
52+
if !sameBinlogFilterRules(s.cfg.CaseSensitive, s.cfg.FilterRules, newCfg.FilterRules) {
53+
return s.newForeignKeyCausalityConfigUpdateError("binlog filter rules")
54+
}
55+
if config.IsForeignKeyChecksEnabled(s.cfg.To.Session) != config.IsForeignKeyChecksEnabled(newCfg.To.Session) {
56+
return s.newForeignKeyCausalityConfigUpdateError("foreign_key_checks")
57+
}
58+
return nil
59+
}
60+
61+
func (s *Syncer) newForeignKeyCausalityConfigUpdateError(field string) error {
62+
return terror.ErrWorkerUpdateSubTaskConfig.Generatef(
63+
"can't update %s when foreign_key_checks=1 and worker-count>1, task: %s; please stop and restart the task with the new config",
64+
field,
65+
s.cfg.Name,
66+
)
67+
}
68+
69+
// The snapshot types below keep only public config fields that affect FK
70+
// causality hot-update semantics. Runtime structs can carry compiled regex or
71+
// cache fields, so comparing them directly would make this check depend on
72+
// runtime state instead of config semantics.
73+
type routeRuleSnapshot struct {
74+
Nil bool
75+
TableExtractor *routeExtractorSnapshot
76+
SchemaExtractor *routeExtractorSnapshot
77+
SourceExtractor *routeExtractorSnapshot
78+
SchemaPattern string
79+
TablePattern string
80+
TargetSchema string
81+
TargetTable string
82+
}
83+
84+
type routeExtractorSnapshot struct {
85+
TargetColumn string
86+
Regexp string
87+
}
88+
89+
type tableFilterRulesSnapshot struct {
90+
DoTables []tableNameSnapshot
91+
DoDBs []string
92+
IgnoreTables []tableNameSnapshot
93+
IgnoreDBs []string
94+
}
95+
96+
type tableNameSnapshot struct {
97+
Schema string
98+
Name string
99+
}
100+
101+
type binlogFilterRuleSnapshot struct {
102+
Nil bool
103+
SchemaPattern string
104+
TablePattern string
105+
Events []string
106+
SQLPattern []string
107+
Action bf.ActionType
108+
}
109+
110+
func sameRouteRules(caseSensitive bool, a, b []*router.TableRule) bool {
111+
if len(a) == 0 && len(b) == 0 {
112+
return true
113+
}
114+
return reflect.DeepEqual(
115+
normalizeRouteRulesForCompare(caseSensitive, a),
116+
normalizeRouteRulesForCompare(caseSensitive, b),
117+
)
118+
}
119+
120+
func normalizeRouteRulesForCompare(caseSensitive bool, rules []*router.TableRule) []routeRuleSnapshot {
121+
if len(rules) == 0 {
122+
return nil
123+
}
124+
result := make([]routeRuleSnapshot, 0, len(rules))
125+
for _, rule := range rules {
126+
if rule == nil {
127+
result = append(result, routeRuleSnapshot{Nil: true})
128+
continue
129+
}
130+
schemaPattern, tablePattern := rule.SchemaPattern, rule.TablePattern
131+
if !caseSensitive {
132+
schemaPattern = strings.ToLower(schemaPattern)
133+
tablePattern = strings.ToLower(tablePattern)
134+
}
135+
result = append(result, routeRuleSnapshot{
136+
TableExtractor: tableExtractorSnapshot(rule.TableExtractor),
137+
SchemaExtractor: schemaExtractorSnapshot(rule.SchemaExtractor),
138+
SourceExtractor: sourceExtractorSnapshot(rule.SourceExtractor),
139+
SchemaPattern: schemaPattern,
140+
TablePattern: tablePattern,
141+
TargetSchema: rule.TargetSchema,
142+
TargetTable: rule.TargetTable,
143+
})
144+
}
145+
return result
146+
}
147+
148+
func tableExtractorSnapshot(extractor *router.TableExtractor) *routeExtractorSnapshot {
149+
if extractor == nil {
150+
return nil
151+
}
152+
return &routeExtractorSnapshot{
153+
TargetColumn: extractor.TargetColumn,
154+
Regexp: extractor.TableRegexp,
155+
}
156+
}
157+
158+
func schemaExtractorSnapshot(extractor *router.SchemaExtractor) *routeExtractorSnapshot {
159+
if extractor == nil {
160+
return nil
161+
}
162+
return &routeExtractorSnapshot{
163+
TargetColumn: extractor.TargetColumn,
164+
Regexp: extractor.SchemaRegexp,
165+
}
166+
}
167+
168+
func sourceExtractorSnapshot(extractor *router.SourceExtractor) *routeExtractorSnapshot {
169+
if extractor == nil {
170+
return nil
171+
}
172+
return &routeExtractorSnapshot{
173+
TargetColumn: extractor.TargetColumn,
174+
Regexp: extractor.SourceRegexp,
175+
}
176+
}
177+
178+
func sameTableFilterRules(caseSensitive bool, a, b *filter.Rules) bool {
179+
if isEmptyTableFilterRules(a) && isEmptyTableFilterRules(b) {
180+
return true
181+
}
182+
return reflect.DeepEqual(
183+
normalizeTableFilterRulesForCompare(caseSensitive, a),
184+
normalizeTableFilterRulesForCompare(caseSensitive, b),
185+
)
186+
}
187+
188+
func isEmptyTableFilterRules(r *filter.Rules) bool {
189+
return r == nil ||
190+
len(r.DoTables) == 0 &&
191+
len(r.DoDBs) == 0 &&
192+
len(r.IgnoreTables) == 0 &&
193+
len(r.IgnoreDBs) == 0
194+
}
195+
196+
func normalizeTableFilterRulesForCompare(caseSensitive bool, rules *filter.Rules) tableFilterRulesSnapshot {
197+
if isEmptyTableFilterRules(rules) {
198+
return tableFilterRulesSnapshot{}
199+
}
200+
doTables := normalizeFilterTablesForCompare(caseSensitive, rules.DoTables)
201+
sortTableNamesForCompare(doTables)
202+
doDBs := normalizeFilterSchemasForCompare(caseSensitive, rules.DoDBs)
203+
sort.Strings(doDBs)
204+
ignoreTables := normalizeFilterTablesForCompare(caseSensitive, rules.IgnoreTables)
205+
sortTableNamesForCompare(ignoreTables)
206+
ignoreDBs := normalizeFilterSchemasForCompare(caseSensitive, rules.IgnoreDBs)
207+
sort.Strings(ignoreDBs)
208+
209+
return tableFilterRulesSnapshot{
210+
DoTables: doTables,
211+
DoDBs: doDBs,
212+
IgnoreTables: ignoreTables,
213+
IgnoreDBs: ignoreDBs,
214+
}
215+
}
216+
217+
func sortTableNamesForCompare(tables []tableNameSnapshot) {
218+
sort.Slice(tables, func(i, j int) bool {
219+
if tables[i].Schema != tables[j].Schema {
220+
return tables[i].Schema < tables[j].Schema
221+
}
222+
return tables[i].Name < tables[j].Name
223+
})
224+
}
225+
226+
func normalizeFilterTablesForCompare(caseSensitive bool, tables []*filter.Table) []tableNameSnapshot {
227+
if len(tables) == 0 {
228+
return nil
229+
}
230+
result := make([]tableNameSnapshot, 0, len(tables))
231+
for _, table := range tables {
232+
if table == nil {
233+
result = append(result, tableNameSnapshot{})
234+
continue
235+
}
236+
schema, name := table.Schema, table.Name
237+
if !caseSensitive {
238+
schema = strings.ToLower(schema)
239+
name = strings.ToLower(name)
240+
}
241+
result = append(result, tableNameSnapshot{Schema: schema, Name: name})
242+
}
243+
return result
244+
}
245+
246+
func normalizeFilterSchemasForCompare(caseSensitive bool, schemas []string) []string {
247+
if len(schemas) == 0 {
248+
return nil
249+
}
250+
result := make([]string, 0, len(schemas))
251+
for _, schema := range schemas {
252+
if !caseSensitive {
253+
schema = strings.ToLower(schema)
254+
}
255+
result = append(result, schema)
256+
}
257+
return result
258+
}
259+
260+
func sameBinlogFilterRules(caseSensitive bool, a, b []*bf.BinlogEventRule) bool {
261+
if len(a) == 0 && len(b) == 0 {
262+
return true
263+
}
264+
return reflect.DeepEqual(
265+
normalizeBinlogFilterRulesForCompare(caseSensitive, a),
266+
normalizeBinlogFilterRulesForCompare(caseSensitive, b),
267+
)
268+
}
269+
270+
func normalizeBinlogFilterRulesForCompare(caseSensitive bool, rules []*bf.BinlogEventRule) []binlogFilterRuleSnapshot {
271+
if len(rules) == 0 {
272+
return nil
273+
}
274+
result := make([]binlogFilterRuleSnapshot, 0, len(rules))
275+
for _, rule := range rules {
276+
if rule == nil {
277+
result = append(result, binlogFilterRuleSnapshot{Nil: true})
278+
continue
279+
}
280+
schemaPattern, tablePattern := rule.SchemaPattern, rule.TablePattern
281+
if !caseSensitive {
282+
schemaPattern = strings.ToLower(schemaPattern)
283+
tablePattern = strings.ToLower(tablePattern)
284+
}
285+
sqlPattern := append([]string(nil), rule.SQLPattern...)
286+
sort.Strings(sqlPattern)
287+
result = append(result, binlogFilterRuleSnapshot{
288+
SchemaPattern: schemaPattern,
289+
TablePattern: tablePattern,
290+
Events: normalizeBinlogFilterEventsForCompare(rule.Events),
291+
SQLPattern: sqlPattern,
292+
Action: rule.Action,
293+
})
294+
}
295+
return result
296+
}
297+
298+
func normalizeBinlogFilterEventsForCompare(events []bf.EventType) []string {
299+
if len(events) == 0 {
300+
return nil
301+
}
302+
result := make([]string, 0, len(events))
303+
for _, event := range events {
304+
result = append(result, strings.ToLower(string(event)))
305+
}
306+
if !containsNoneBinlogFilterEvent(result) {
307+
sort.Strings(result)
308+
}
309+
return result
310+
}
311+
312+
func containsNoneBinlogFilterEvent(events []string) bool {
313+
for _, event := range events {
314+
switch event {
315+
case string(bf.NoneEvent), string(bf.NoneDDL), string(bf.NoneDML):
316+
return true
317+
}
318+
}
319+
return false
320+
}

dm/syncer/foreign_key_causality_route_test.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,7 @@ func TestPrepareDownStreamTableInfoCachesForeignKeyRouteTopologyCheck(t *testing
673673
require.NoError(t, mock.ExpectationsWereMet())
674674
}
675675

676-
func TestUpdateInvalidatesForeignKeyRouteTopologyCheckCache(t *testing.T) {
676+
func TestUpdateRejectsFKRouteChangeAfterTopologyCheck(t *testing.T) {
677677
syncer, mock := newForeignKeyRouteTestSyncer(t, 2)
678678
syncer.timezone = time.UTC
679679
setForeignKeyRouteTestRoutes(t, syncer, []*router.TableRule{
@@ -703,6 +703,7 @@ func TestUpdateInvalidatesForeignKeyRouteTopologyCheckCache(t *testing.T) {
703703
dti, err := syncer.prepareDownStreamTableInfo(tcontext.Background(), sourceTable, targetTable, originTI)
704704
require.NoError(t, err)
705705
require.Len(t, dti.ForeignKeyRelations, 1)
706+
require.True(t, syncer.isForeignKeyRouteTopologyChecked())
706707

707708
newCfg, err := syncer.cfg.Clone()
708709
require.NoError(t, err)
@@ -711,14 +712,10 @@ func TestUpdateInvalidatesForeignKeyRouteTopologyCheckCache(t *testing.T) {
711712
{SchemaPattern: "db", TablePattern: "child", TargetSchema: "db_r", TargetTable: "child_r"},
712713
{SchemaPattern: "db", TablePattern: "child_shadow", TargetSchema: "db_r", TargetTable: "child_r"},
713714
}
714-
require.NoError(t, syncer.Update(context.Background(), newCfg))
715-
716-
expectFetchAllDoTables(mock, "db", "parent", "child", "child_shadow")
717-
_, err = syncer.prepareDownStreamTableInfo(tcontext.Background(), sourceTable, targetTable, originTI)
718-
require.ErrorContains(t, err, "shared-target route")
719-
require.ErrorContains(t, err, "`db_r`.`child_r`")
720-
require.ErrorContains(t, err, "`db`.`child`")
721-
require.ErrorContains(t, err, "`db`.`child_shadow`")
715+
err = syncer.Update(context.Background(), newCfg)
716+
require.ErrorContains(t, err, "route rules")
717+
require.ErrorContains(t, err, "foreign_key_checks=1 and worker-count>1")
718+
require.True(t, syncer.isForeignKeyRouteTopologyChecked())
722719
require.NoError(t, mock.ExpectationsWereMet())
723720
}
724721

dm/syncer/syncer.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3395,6 +3395,9 @@ func (s *Syncer) CheckCanUpdateCfg(newCfg *config.SubTaskConfig) error {
33953395
return terror.ErrSyncerUnitUpdateConfigInSharding.Generate(tables)
33963396
}
33973397
}
3398+
if err := s.checkForeignKeyCausalityConfigUpdate(newCfg); err != nil {
3399+
return err
3400+
}
33983401

33993402
oldCfg, err := s.cfg.Clone()
34003403
if err != nil {
@@ -3444,6 +3447,9 @@ func (s *Syncer) Update(ctx context.Context, cfg *config.SubTaskConfig) (err err
34443447
return terror.ErrSyncerUnitUpdateConfigInSharding.Generate(tables)
34453448
}
34463449
}
3450+
if err := s.checkForeignKeyCausalityConfigUpdate(cfg); err != nil {
3451+
return err
3452+
}
34473453

34483454
var (
34493455
oldBaList *filter.Filter

0 commit comments

Comments
 (0)