Skip to content

Commit 0263a20

Browse files
Fix #1502 Add triggers support (#1503)
* Add trigger support to gh-ost based on openark#30 * Add comprehensive test cases for trigger support functionality * Fix trigger-basic test by adding required --trigger-suffix parameter and remove fail-trigger-unsupported test * Update trigger suffix in local test configurations Modify extra_args files for trigger-complex, trigger-multiple, and trigger-self-reference tests to use different trigger suffixes * Add --remove-trigger-suffix-if-exists to local test configurations Update extra_args files for trigger tests to include the new --remove-trigger-suffix-if-exists option, ensuring consistent trigger handling across different test scenarios * Fix trigger-long-name test by reducing trigger name length to be valid * Standardize trigger drop statements in local test configurations Update create.sql files across trigger test scenarios to: - Consistently drop both original and ghost triggers - Ensure clean slate before creating triggers - Align with recent trigger suffix changes This change improves test reliability and consistency by explicitly dropping all potential trigger variations before test setup. * Consolidate and enhance trigger test configurations Refactor local test scenarios for triggers by: - Merging multiple trigger test configurations into comprehensive test cases - Updating create.sql files with more complex and diverse trigger scenarios - Standardizing trigger and table setup across different test configurations - Removing redundant test directories while preserving test coverage This change simplifies the trigger testing infrastructure and provides more robust test coverage for gh-ost's trigger handling capabilities. * Add debug logging for ghost trigger validation process Enhance ghost trigger existence check by adding detailed debug logging to: - Log the ghost trigger name being searched - Log the database schema and query details - Log when an existing ghost trigger is found This change improves visibility into the trigger validation process, making troubleshooting easier during migration scenarios. * Improve ghost trigger validation with enhanced logging and verification Modify validateGhostTriggersDontExist() to: - Add a direct query to log all triggers in the database schema - Refactor trigger existence check to use count-based query - Improve debug logging for trigger validation process - Provide more detailed error reporting for existing ghost triggers This change enhances the robustness and observability of the trigger validation mechanism in gh-ost's migration process. * Refactor ghost trigger validation to improve logging and error detection Simplify and enhance the validateGhostTriggersDontExist() method by: - Removing redundant direct query logging - Streamlining trigger existence check - Improving debug logging for trigger validation - Consolidating trigger existence detection logic The changes provide more concise and focused trigger validation with clearer error reporting. * Simplify ghost trigger validation query and reduce logging verbosity Refactor validateGhostTriggersDontExist() to: - Streamline trigger existence check query - Remove redundant debug logging statements - Use a more concise approach to detecting existing triggers The changes reduce code complexity while maintaining the core validation logic for ghost triggers. * Enhance ghost trigger validation query to include table name filter Modify validateGhostTriggersDontExist() to: - Add table name filter to trigger existence check query - Improve specificity of ghost trigger detection - Prevent false positives from similarly named triggers in different tables The change ensures more precise ghost trigger validation by incorporating the original table name into the query criteria. * Enhance trigger test configuration with advanced features and consolidated test scenarios Update trigger-advanced-features test configuration to: - Add new column 'color' and 'modified_count' to test table - Implement more complex trigger logic for color and count tracking - Consolidate trigger test scenarios with richer data transformations - Modify event to test both numeric and color-based updates - Remove redundant trigger-basic-features directory The changes provide a more comprehensive and nuanced test suite for gh-ost's trigger handling capabilities, demonstrating advanced trigger behaviors and self-referencing updates. * Fix lint errors * Update trigger test configuration with suffix change Modify the extra_args file to use '_ght' trigger suffix instead of '_gho', maintaining consistency with recent trigger test configuration updates. * Remove gh-ost-ci-env submodule Clean up repository by removing the gh-ost-ci-env submodule, which appears to be no longer needed in the project structure. * Update create.sql --------- Co-authored-by: Yakir Gibraltar <yakir.g@taboola.com>
1 parent 7ea3047 commit 0263a20

File tree

9 files changed

+407
-3
lines changed

9 files changed

+407
-3
lines changed

Diff for: go/base/context.go

+23
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"sync"
1515
"sync/atomic"
1616
"time"
17+
"unicode/utf8"
1718

1819
uuid "github.com/google/uuid"
1920

@@ -237,6 +238,11 @@ type MigrationContext struct {
237238
MigrationIterationRangeMaxValues *sql.ColumnValues
238239
ForceTmpTableName string
239240

241+
IncludeTriggers bool
242+
RemoveTriggerSuffix bool
243+
TriggerSuffix string
244+
Triggers []mysql.Trigger
245+
240246
recentBinlogCoordinates mysql.BinlogCoordinates
241247

242248
BinlogSyncerMaxReconnectAttempts int
@@ -924,3 +930,20 @@ func (this *MigrationContext) ReadConfigFile() error {
924930

925931
return nil
926932
}
933+
934+
// getGhostTriggerName generates the name of a ghost trigger, based on original trigger name
935+
// or a given trigger name
936+
func (this *MigrationContext) GetGhostTriggerName(triggerName string) string {
937+
if this.RemoveTriggerSuffix && strings.HasSuffix(triggerName, this.TriggerSuffix) {
938+
return strings.TrimSuffix(triggerName, this.TriggerSuffix)
939+
}
940+
// else
941+
return triggerName + this.TriggerSuffix
942+
}
943+
944+
// validateGhostTriggerLength check if the ghost trigger name length is not more than 64 characters
945+
func (this *MigrationContext) ValidateGhostTriggerLengthBelowMaxLength(triggerName string) bool {
946+
ghostTriggerName := this.GetGhostTriggerName(triggerName)
947+
948+
return utf8.RuneCountInString(ghostTriggerName) <= mysql.MaxTableNameLength
949+
}

Diff for: go/base/context_test.go

+63
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package base
77

88
import (
99
"os"
10+
"strings"
1011
"testing"
1112
"time"
1213

@@ -58,6 +59,68 @@ func TestGetTableNames(t *testing.T) {
5859
}
5960
}
6061

62+
func TestGetTriggerNames(t *testing.T) {
63+
{
64+
context := NewMigrationContext()
65+
context.TriggerSuffix = "_gho"
66+
require.Equal(t, "my_trigger"+context.TriggerSuffix, context.GetGhostTriggerName("my_trigger"))
67+
}
68+
{
69+
context := NewMigrationContext()
70+
context.TriggerSuffix = "_gho"
71+
context.RemoveTriggerSuffix = true
72+
require.Equal(t, "my_trigger"+context.TriggerSuffix, context.GetGhostTriggerName("my_trigger"))
73+
}
74+
{
75+
context := NewMigrationContext()
76+
context.TriggerSuffix = "_gho"
77+
context.RemoveTriggerSuffix = true
78+
require.Equal(t, "my_trigger", context.GetGhostTriggerName("my_trigger_gho"))
79+
}
80+
{
81+
context := NewMigrationContext()
82+
context.TriggerSuffix = "_gho"
83+
context.RemoveTriggerSuffix = false
84+
require.Equal(t, "my_trigger_gho_gho", context.GetGhostTriggerName("my_trigger_gho"))
85+
}
86+
}
87+
88+
func TestValidateGhostTriggerLengthBelowMaxLength(t *testing.T) {
89+
{
90+
context := NewMigrationContext()
91+
context.TriggerSuffix = "_gho"
92+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength("my_trigger"))
93+
}
94+
{
95+
context := NewMigrationContext()
96+
context.TriggerSuffix = "_ghost"
97+
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost"
98+
}
99+
{
100+
context := NewMigrationContext()
101+
context.TriggerSuffix = "_ghost"
102+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 3))) // 48 characters + "_ghost"
103+
}
104+
{
105+
context := NewMigrationContext()
106+
context.TriggerSuffix = "_ghost"
107+
context.RemoveTriggerSuffix = true
108+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost" removed
109+
}
110+
{
111+
context := NewMigrationContext()
112+
context.TriggerSuffix = "_ghost"
113+
context.RemoveTriggerSuffix = true
114+
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"X")) // 65 characters + "_ghost" not removed
115+
}
116+
{
117+
context := NewMigrationContext()
118+
context.TriggerSuffix = "_ghost"
119+
context.RemoveTriggerSuffix = true
120+
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"_ghost")) // 70 characters + last "_ghost" removed
121+
}
122+
}
123+
61124
func TestReadConfigFile(t *testing.T) {
62125
{
63126
context := NewMigrationContext()

Diff for: go/cmd/gh-ost/main.go

+19-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/url"
1212
"os"
1313
"os/signal"
14+
"regexp"
1415
"syscall"
1516

1617
"github.com/github/gh-ost/go/base"
@@ -137,6 +138,10 @@ func main() {
137138
flag.UintVar(&migrationContext.ReplicaServerId, "replica-server-id", 99999, "server id used by gh-ost process. Default: 99999")
138139
flag.IntVar(&migrationContext.BinlogSyncerMaxReconnectAttempts, "binlogsyncer-max-reconnect-attempts", 0, "when master node fails, the maximum number of binlog synchronization attempts to reconnect. 0 is unlimited")
139140

141+
flag.BoolVar(&migrationContext.IncludeTriggers, "include-triggers", false, "When true, the triggers (if exist) will be created on the new table")
142+
flag.StringVar(&migrationContext.TriggerSuffix, "trigger-suffix", "", "Add a suffix to the trigger name (i.e '_v2'). Requires '--include-triggers'")
143+
flag.BoolVar(&migrationContext.RemoveTriggerSuffix, "remove-trigger-suffix-if-exists", false, "Remove given suffix from name of trigger. Requires '--include-triggers' and '--trigger-suffix'")
144+
140145
maxLoad := flag.String("max-load", "", "Comma delimited status-name=threshold. e.g: 'Threads_running=100,Threads_connected=500'. When status exceeds threshold, app throttles writes")
141146
criticalLoad := flag.String("critical-load", "", "Comma delimited status-name=threshold, same format as --max-load. When status exceeds threshold, app panics and quits")
142147
flag.Int64Var(&migrationContext.CriticalLoadIntervalMilliseconds, "critical-load-interval-millis", 0, "When 0, migration immediately bails out upon meeting critical-load. When non-zero, a second check is done after given interval, and migration only bails out if 2nd check still meets critical load")
@@ -257,7 +262,20 @@ func main() {
257262
migrationContext.Log.Fatal("--ssl-allow-insecure requires --ssl")
258263
}
259264
if *replicationLagQuery != "" {
260-
migrationContext.Log.Warning("--replication-lag-query is deprecated")
265+
migrationContext.Log.Warningf("--replication-lag-query is deprecated")
266+
}
267+
if migrationContext.IncludeTriggers && migrationContext.TriggerSuffix == "" {
268+
migrationContext.Log.Fatalf("--trigger-suffix must be used with --include-triggers")
269+
}
270+
if !migrationContext.IncludeTriggers && migrationContext.TriggerSuffix != "" {
271+
migrationContext.Log.Fatalf("--trigger-suffix cannot be be used without --include-triggers")
272+
}
273+
if migrationContext.TriggerSuffix != "" {
274+
regex := regexp.MustCompile(`^[\da-zA-Z_]+$`)
275+
276+
if !regex.Match([]byte(migrationContext.TriggerSuffix)) {
277+
migrationContext.Log.Fatalf("--trigger-suffix must contain only alpha numeric characters and underscore (0-9,a-z,A-Z,_)")
278+
}
261279
}
262280
if *storageEngine == "rocksdb" {
263281
migrationContext.Log.Warning("RocksDB storage engine support is experimental")

Diff for: go/logic/applier.go

+50
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,56 @@ func (this *Applier) dropTable(tableName string) error {
415415
return nil
416416
}
417417

418+
// dropTriggers drop the triggers on the applied host
419+
func (this *Applier) DropTriggersFromGhost() error {
420+
if len(this.migrationContext.Triggers) > 0 {
421+
for _, trigger := range this.migrationContext.Triggers {
422+
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
423+
query := fmt.Sprintf("drop trigger if exists %s", sql.EscapeName(triggerName))
424+
_, err := sqlutils.ExecNoPrepare(this.db, query)
425+
if err != nil {
426+
return err
427+
}
428+
this.migrationContext.Log.Infof("Trigger '%s' dropped", triggerName)
429+
}
430+
}
431+
return nil
432+
}
433+
434+
// createTriggers creates the triggers on the applied host
435+
func (this *Applier) createTriggers(tableName string) error {
436+
if len(this.migrationContext.Triggers) > 0 {
437+
for _, trigger := range this.migrationContext.Triggers {
438+
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
439+
query := fmt.Sprintf(`create /* gh-ost */ trigger %s %s %s on %s.%s for each row
440+
%s`,
441+
sql.EscapeName(triggerName),
442+
trigger.Timing,
443+
trigger.Event,
444+
sql.EscapeName(this.migrationContext.DatabaseName),
445+
sql.EscapeName(tableName),
446+
trigger.Statement,
447+
)
448+
this.migrationContext.Log.Infof("Createing trigger %s on %s.%s",
449+
sql.EscapeName(triggerName),
450+
sql.EscapeName(this.migrationContext.DatabaseName),
451+
sql.EscapeName(tableName),
452+
)
453+
if _, err := sqlutils.ExecNoPrepare(this.db, query); err != nil {
454+
return err
455+
}
456+
}
457+
this.migrationContext.Log.Infof("Triggers created on %s", tableName)
458+
}
459+
return nil
460+
}
461+
462+
// CreateTriggers creates the original triggers on applier host
463+
func (this *Applier) CreateTriggersOnGhost() error {
464+
err := this.createTriggers(this.migrationContext.GetGhostTableName())
465+
return err
466+
}
467+
418468
// DropChangelogTable drops the changelog table on the applier host
419469
func (this *Applier) DropChangelogTable() error {
420470
return this.dropTable(this.migrationContext.GetChangelogTableName())

Diff for: go/logic/inspect.go

+62-2
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ func (this *Inspector) validateTableForeignKeys(allowChildForeignKeys bool) erro
531531
return nil
532532
}
533533

534-
// validateTableTriggers makes sure no triggers exist on the migrated table
534+
// validateTableTriggers makes sure no triggers exist on the migrated table. if --include_triggers is used then it fetches the triggers
535535
func (this *Inspector) validateTableTriggers() error {
536536
query := `
537537
SELECT /* gh-ost */ COUNT(*) AS num_triggers
@@ -553,12 +553,72 @@ func (this *Inspector) validateTableTriggers() error {
553553
return err
554554
}
555555
if numTriggers > 0 {
556-
return this.migrationContext.Log.Errorf("Found triggers on %s.%s. Triggers are not supported at this time. Bailing out", sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
556+
if this.migrationContext.IncludeTriggers {
557+
this.migrationContext.Log.Infof("Found %d triggers on %s.%s.", numTriggers, sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
558+
this.migrationContext.Triggers, err = mysql.GetTriggers(this.db, this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName)
559+
if err != nil {
560+
return err
561+
}
562+
if err := this.validateGhostTriggersDontExist(); err != nil {
563+
return err
564+
}
565+
if err := this.validateGhostTriggersLength(); err != nil {
566+
return err
567+
}
568+
return nil
569+
}
570+
return this.migrationContext.Log.Errorf("Found triggers on %s.%s. Tables with triggers are supported only when using \"include-triggers\" flag. Bailing out", sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
557571
}
558572
this.migrationContext.Log.Debugf("Validated no triggers exist on table")
559573
return nil
560574
}
561575

576+
// verifyTriggersDontExist verifies before createing new triggers we want to make sure these triggers dont exist already in the DB
577+
func (this *Inspector) validateGhostTriggersDontExist() error {
578+
if len(this.migrationContext.Triggers) > 0 {
579+
var foundTriggers []string
580+
for _, trigger := range this.migrationContext.Triggers {
581+
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
582+
query := "select 1 from information_schema.triggers where trigger_name = ? and trigger_schema = ? and event_object_table = ?"
583+
err := sqlutils.QueryRowsMap(this.db, query, func(rowMap sqlutils.RowMap) error {
584+
triggerExists := rowMap.GetInt("1")
585+
if triggerExists == 1 {
586+
foundTriggers = append(foundTriggers, triggerName)
587+
}
588+
return nil
589+
},
590+
triggerName,
591+
this.migrationContext.DatabaseName,
592+
this.migrationContext.OriginalTableName,
593+
)
594+
if err != nil {
595+
return err
596+
}
597+
}
598+
if len(foundTriggers) > 0 {
599+
return this.migrationContext.Log.Errorf("Found gh-ost triggers (%s). Please use a different suffix or drop them. Bailing out", strings.Join(foundTriggers, ","))
600+
}
601+
}
602+
603+
return nil
604+
}
605+
606+
func (this *Inspector) validateGhostTriggersLength() error {
607+
if len(this.migrationContext.Triggers) > 0 {
608+
var foundTriggers []string
609+
for _, trigger := range this.migrationContext.Triggers {
610+
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
611+
if ok := this.migrationContext.ValidateGhostTriggerLengthBelowMaxLength(triggerName); !ok {
612+
foundTriggers = append(foundTriggers, triggerName)
613+
}
614+
}
615+
if len(foundTriggers) > 0 {
616+
return this.migrationContext.Log.Errorf("Gh-ost triggers (%s) length > %d characters. Bailing out", strings.Join(foundTriggers, ","), mysql.MaxTableNameLength)
617+
}
618+
}
619+
return nil
620+
}
621+
562622
// estimateTableRowsViaExplain estimates number of rows on original table
563623
func (this *Inspector) estimateTableRowsViaExplain() error {
564624
query := fmt.Sprintf(`explain select /* gh-ost */ * from %s.%s where 1=1`, sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))

Diff for: go/logic/migrator.go

+13
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,12 @@ func (this *Migrator) cutOverTwoStep() (err error) {
632632
if err := this.retryOperation(this.waitForEventsUpToLock); err != nil {
633633
return err
634634
}
635+
// If we need to create triggers we need to do it here (only create part)
636+
if this.migrationContext.IncludeTriggers && len(this.migrationContext.Triggers) > 0 {
637+
if err := this.retryOperation(this.applier.CreateTriggersOnGhost); err != nil {
638+
return err
639+
}
640+
}
635641
if err := this.retryOperation(this.applier.SwapTablesQuickAndBumpy); err != nil {
636642
return err
637643
}
@@ -676,6 +682,13 @@ func (this *Migrator) atomicCutOver() (err error) {
676682
return this.migrationContext.Log.Errore(err)
677683
}
678684

685+
// If we need to create triggers we need to do it here (only create part)
686+
if this.migrationContext.IncludeTriggers && len(this.migrationContext.Triggers) > 0 {
687+
if err := this.applier.CreateTriggersOnGhost(); err != nil {
688+
this.migrationContext.Log.Errore(err)
689+
}
690+
}
691+
679692
// Step 2
680693
// We now attempt an atomic RENAME on original & ghost tables, and expect it to block.
681694
this.migrationContext.RenameTablesStartTime = time.Now()

Diff for: go/mysql/utils.go

+28
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ type ReplicationLagResult struct {
3030
Err error
3131
}
3232

33+
type Trigger struct {
34+
Name string
35+
Event string
36+
Statement string
37+
Timing string
38+
}
39+
3340
func NewNoReplicationLagResult() *ReplicationLagResult {
3441
return &ReplicationLagResult{Lag: 0, Err: nil}
3542
}
@@ -224,3 +231,24 @@ func Kill(db *gosql.DB, connectionID string) error {
224231
_, err := db.Exec(`KILL QUERY %s`, connectionID)
225232
return err
226233
}
234+
235+
// GetTriggers reads trigger list from given table
236+
func GetTriggers(db *gosql.DB, databaseName, tableName string) (triggers []Trigger, err error) {
237+
query := fmt.Sprintf(`select trigger_name as name, event_manipulation as event, action_statement as statement, action_timing as timing
238+
from information_schema.triggers
239+
where trigger_schema = '%s' and event_object_table = '%s'`, databaseName, tableName)
240+
241+
err = sqlutils.QueryRowsMap(db, query, func(rowMap sqlutils.RowMap) error {
242+
triggers = append(triggers, Trigger{
243+
Name: rowMap.GetString("name"),
244+
Event: rowMap.GetString("event"),
245+
Statement: rowMap.GetString("statement"),
246+
Timing: rowMap.GetString("timing"),
247+
})
248+
return nil
249+
})
250+
if err != nil {
251+
return nil, err
252+
}
253+
return triggers, nil
254+
}

0 commit comments

Comments
 (0)