Skip to content

Commit c6f95cc

Browse files
yakirgbYakir-Taboolameiji163
authored
Fix 4 trigger handling bugs (#1626)
* fix: remove double-transformation in trigger length validation ValidateGhostTriggerLengthBelowMaxLength was calling GetGhostTriggerName on an already-transformed name, adding the suffix twice. This caused valid trigger names (ghost name <= 64 chars) to be falsely rejected. The caller in inspect.go:627 already transforms the name via GetGhostTriggerName before passing it, so the validation function should check the length as-is. Unit tests updated to reflect the correct call pattern: transform first with GetGhostTriggerName, then validate the result. Added boundary tests for exactly 64 and 65 char names. * fix: return error from trigger creation during atomic cut-over During atomic cut-over, if CreateTriggersOnGhost failed, the error was logged but not returned. The migration continued and completed without triggers, silently losing them. The two-step cut-over (line 793) already correctly returns the error. This aligns the atomic cut-over to do the same. * fix: check trigger name uniqueness per schema, not per table validateGhostTriggersDontExist was filtering by event_object_table, only checking if the ghost trigger name existed on the original table. MySQL trigger names are unique per schema, so a trigger with the same name on any other table would block CREATE TRIGGER but pass validation. Remove the event_object_table filter to check trigger_name + trigger_schema only, matching MySQL's uniqueness constraint. * fix: use parameterized query in GetTriggers to prevent SQL injection GetTriggers used fmt.Sprintf with string interpolation for database and table names, causing SQL syntax errors with special characters and potential SQL injection. Switched to parameterized query with ? placeholders, matching the safe pattern already used in inspect.go:553-559. * test: add regression tests for trigger handling bugs Add two integration tests: - trigger-long-name-validation: verifies 60-char trigger names (64-char ghost name) are not falsely rejected by double-transform - trigger-ghost-name-conflict: verifies validation detects ghost trigger name conflicts on other tables in the same schema * style: gofmt context_test.go --------- Co-authored-by: Yakir Gibraltar <yakir.g@taboola.com> Co-authored-by: meiji163 <meiji163@github.com>
1 parent aadbb79 commit c6f95cc

File tree

11 files changed

+124
-17
lines changed

11 files changed

+124
-17
lines changed

go/base/context.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -970,9 +970,8 @@ func (this *MigrationContext) GetGhostTriggerName(triggerName string) string {
970970
return triggerName + this.TriggerSuffix
971971
}
972972

973-
// validateGhostTriggerLength check if the ghost trigger name length is not more than 64 characters
973+
// ValidateGhostTriggerLengthBelowMaxLength checks if the given trigger name (already transformed
974+
// by GetGhostTriggerName) does not exceed the maximum allowed length.
974975
func (this *MigrationContext) ValidateGhostTriggerLengthBelowMaxLength(triggerName string) bool {
975-
ghostTriggerName := this.GetGhostTriggerName(triggerName)
976-
977-
return utf8.RuneCountInString(ghostTriggerName) <= mysql.MaxTableNameLength
976+
return utf8.RuneCountInString(triggerName) <= mysql.MaxTableNameLength
978977
}

go/base/context_test.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,38 +86,69 @@ func TestGetTriggerNames(t *testing.T) {
8686
}
8787

8888
func TestValidateGhostTriggerLengthBelowMaxLength(t *testing.T) {
89+
// Tests simulate the real call pattern: GetGhostTriggerName first, then validate the result.
8990
{
91+
// Short trigger name with suffix appended: well under 64 chars
9092
context := NewMigrationContext()
9193
context.TriggerSuffix = "_gho"
92-
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength("my_trigger"))
94+
ghostName := context.GetGhostTriggerName("my_trigger") // "my_trigger_gho" = 14 chars
95+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
9396
}
9497
{
98+
// 64-char original + "_ghost" suffix = 70 chars → exceeds limit
9599
context := NewMigrationContext()
96100
context.TriggerSuffix = "_ghost"
97-
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost"
101+
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4)) // 64 + 6 = 70
102+
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
98103
}
99104
{
105+
// 48-char original + "_ghost" suffix = 54 chars → valid
100106
context := NewMigrationContext()
101107
context.TriggerSuffix = "_ghost"
102-
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 3))) // 48 characters + "_ghost"
108+
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 3)) // 48 + 6 = 54
109+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
103110
}
104111
{
112+
// RemoveTriggerSuffix: 64-char name ending in "_ghost" → suffix removed → 58 chars → valid
105113
context := NewMigrationContext()
106114
context.TriggerSuffix = "_ghost"
107115
context.RemoveTriggerSuffix = true
108-
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost" removed
116+
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4)) // suffix removed → 58
117+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
109118
}
110119
{
120+
// RemoveTriggerSuffix: name doesn't end in suffix → suffix appended → 65 + 6 = 71 chars → exceeds
111121
context := NewMigrationContext()
112122
context.TriggerSuffix = "_ghost"
113123
context.RemoveTriggerSuffix = true
114-
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"X")) // 65 characters + "_ghost" not removed
124+
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4) + "X") // no match, appended → 71
125+
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
115126
}
116127
{
128+
// RemoveTriggerSuffix: 70-char name ending in "_ghost" → suffix removed → 64 chars → exactly at limit → valid
117129
context := NewMigrationContext()
118130
context.TriggerSuffix = "_ghost"
119131
context.RemoveTriggerSuffix = true
120-
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"_ghost")) // 70 characters + last "_ghost" removed
132+
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4) + "_ghost") // suffix removed → 64
133+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
134+
}
135+
{
136+
// Edge case: exactly 64 chars after transformation → valid (boundary test)
137+
context := NewMigrationContext()
138+
context.TriggerSuffix = "_ght"
139+
originalName := strings.Repeat("x", 60) // 60 chars
140+
ghostName := context.GetGhostTriggerName(originalName) // 60 + 4 = 64
141+
require.Equal(t, 64, len(ghostName))
142+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
143+
}
144+
{
145+
// Edge case: 65 chars after transformation → exceeds (boundary test)
146+
context := NewMigrationContext()
147+
context.TriggerSuffix = "_ght"
148+
originalName := strings.Repeat("x", 61) // 61 chars
149+
ghostName := context.GetGhostTriggerName(originalName) // 61 + 4 = 65
150+
require.Equal(t, 65, len(ghostName))
151+
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
121152
}
122153
}
123154

go/logic/inspect.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ func (this *Inspector) validateGhostTriggersDontExist() error {
596596
var foundTriggers []string
597597
for _, trigger := range this.migrationContext.Triggers {
598598
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
599-
query := "select 1 from information_schema.triggers where trigger_name = ? and trigger_schema = ? and event_object_table = ?"
599+
query := "select 1 from information_schema.triggers where trigger_name = ? and trigger_schema = ?"
600600
err := sqlutils.QueryRowsMap(this.db, query, func(rowMap sqlutils.RowMap) error {
601601
triggerExists := rowMap.GetInt("1")
602602
if triggerExists == 1 {
@@ -606,7 +606,6 @@ func (this *Inspector) validateGhostTriggersDontExist() error {
606606
},
607607
triggerName,
608608
this.migrationContext.DatabaseName,
609-
this.migrationContext.OriginalTableName,
610609
)
611610
if err != nil {
612611
return err

go/logic/migrator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,7 @@ func (this *Migrator) atomicCutOver() (err error) {
842842
// If we need to create triggers we need to do it here (only create part)
843843
if this.migrationContext.IncludeTriggers && len(this.migrationContext.Triggers) > 0 {
844844
if err := this.applier.CreateTriggersOnGhost(); err != nil {
845-
this.migrationContext.Log.Errore(err)
845+
return this.migrationContext.Log.Errore(err)
846846
}
847847
}
848848

go/mysql/utils.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,9 @@ func Kill(db *gosql.DB, connectionID string) error {
248248

249249
// GetTriggers reads trigger list from given table
250250
func GetTriggers(db *gosql.DB, databaseName, tableName string) (triggers []Trigger, err error) {
251-
query := fmt.Sprintf(`select trigger_name as name, event_manipulation as event, action_statement as statement, action_timing as timing
252-
from information_schema.triggers
253-
where trigger_schema = '%s' and event_object_table = '%s'`, databaseName, tableName)
251+
query := `select trigger_name as name, event_manipulation as event, action_statement as statement, action_timing as timing
252+
from information_schema.triggers
253+
where trigger_schema = ? and event_object_table = ?`
254254

255255
err = sqlutils.QueryRowsMap(db, query, func(rowMap sqlutils.RowMap) error {
256256
triggers = append(triggers, Trigger{
@@ -260,7 +260,7 @@ func GetTriggers(db *gosql.DB, databaseName, tableName string) (triggers []Trigg
260260
Timing: rowMap.GetString("timing"),
261261
})
262262
return nil
263-
})
263+
}, databaseName, tableName)
264264
if err != nil {
265265
return nil, err
266266
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- Bug #3 regression test: validateGhostTriggersDontExist must check whole schema
2+
-- MySQL trigger names are unique per SCHEMA, not per table.
3+
-- The validation must detect a trigger with the ghost name on ANY table in the schema.
4+
5+
drop trigger if exists gh_ost_test_ai_ght;
6+
drop trigger if exists gh_ost_test_ai;
7+
drop table if exists gh_ost_test_other;
8+
drop table if exists gh_ost_test;
9+
10+
create table gh_ost_test (
11+
id int auto_increment,
12+
i int not null,
13+
primary key(id)
14+
) auto_increment=1;
15+
16+
-- This trigger has the _ght suffix (simulating a previous migration left it).
17+
-- Ghost name = "gh_ost_test_ai" (suffix removed).
18+
create trigger gh_ost_test_ai_ght
19+
after insert on gh_ost_test for each row
20+
set @dummy = 1;
21+
22+
-- Create ANOTHER table with a trigger named "gh_ost_test_ai" (the ghost name).
23+
-- Validation must detect this conflict even though the trigger is on a different table.
24+
create table gh_ost_test_other (
25+
id int auto_increment,
26+
primary key(id)
27+
);
28+
29+
create trigger gh_ost_test_ai
30+
after insert on gh_ost_test_other for each row
31+
set @dummy = 1;
32+
33+
insert into gh_ost_test values (null, 11);
34+
insert into gh_ost_test values (null, 13);
35+
insert into gh_ost_test values (null, 17);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
drop trigger if exists gh_ost_test_ai;
2+
drop table if exists gh_ost_test_other;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Found gh-ost triggers
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--include-triggers --trigger-suffix=_ght --remove-trigger-suffix-if-exists
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
-- Bug #1: Double-transformation in trigger length validation
2+
-- A trigger with a 60-char name should be valid: ghost name = 60 + 4 (_ght) = 64 chars (max allowed).
3+
-- But validateGhostTriggersLength() applies GetGhostTriggerName() twice,
4+
-- computing 60 + 4 + 4 = 68, which falsely exceeds the 64-char limit.
5+
6+
drop table if exists gh_ost_test;
7+
8+
create table gh_ost_test (
9+
id int auto_increment,
10+
i int not null,
11+
primary key(id)
12+
) auto_increment=1;
13+
14+
-- Trigger name is exactly 60 characters (padded to 60).
15+
-- Ghost name with _ght suffix = 64 chars = exactly at the MySQL limit.
16+
-- 60 chars: trigger_long_name_padding_aaaaaaaaaaaaaaaaaaaaa_60chars_xxxx
17+
create trigger trigger_long_name_padding_aaaaaaaaaaaaaaaaaaaaa_60chars_xxxx
18+
after insert on gh_ost_test for each row
19+
set @dummy = 1;
20+
21+
insert into gh_ost_test values (null, 11);
22+
insert into gh_ost_test values (null, 13);
23+
insert into gh_ost_test values (null, 17);
24+
25+
drop event if exists gh_ost_test;
26+
delimiter ;;
27+
create event gh_ost_test
28+
on schedule every 1 second
29+
starts current_timestamp
30+
ends current_timestamp + interval 60 second
31+
on completion not preserve
32+
enable
33+
do
34+
begin
35+
insert into gh_ost_test values (null, 23);
36+
update gh_ost_test set i=i+1 where id=1;
37+
end ;;
38+
delimiter ;

0 commit comments

Comments
 (0)