Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
94fa782
Add trigger support to gh-ost based on https://github.com/openark/gh-…
Yakir-Taboola Mar 2, 2025
afd5dd9
Add comprehensive test cases for trigger support functionality
Yakir-Taboola Mar 2, 2025
82abf3c
Fix trigger-basic test by adding required --trigger-suffix parameter …
Yakir-Taboola Mar 2, 2025
7381b9b
Update trigger suffix in local test configurations
Yakir-Taboola Mar 2, 2025
d3fa7a1
Add --remove-trigger-suffix-if-exists to local test configurations
Yakir-Taboola Mar 2, 2025
0b2183e
Fix trigger-long-name test by reducing trigger name length to be valid
Yakir-Taboola Mar 2, 2025
f8a820a
Standardize trigger drop statements in local test configurations
Yakir-Taboola Mar 2, 2025
186ce33
Consolidate and enhance trigger test configurations
Yakir-Taboola Mar 2, 2025
a6518ee
Add debug logging for ghost trigger validation process
Yakir-Taboola Mar 2, 2025
e764d7b
Improve ghost trigger validation with enhanced logging and verification
Yakir-Taboola Mar 2, 2025
73e42eb
Refactor ghost trigger validation to improve logging and error detection
Yakir-Taboola Mar 2, 2025
2006998
Simplify ghost trigger validation query and reduce logging verbosity
Yakir-Taboola Mar 2, 2025
3b00d64
Enhance ghost trigger validation query to include table name filter
Yakir-Taboola Mar 2, 2025
dc9c563
Enhance trigger test configuration with advanced features and consoli…
Yakir-Taboola Mar 2, 2025
e83c4e2
Fix lint errors
Yakir-Taboola Mar 3, 2025
e0d325a
Update trigger test configuration with suffix change
Yakir-Taboola Mar 3, 2025
8ec1811
Remove gh-ost-ci-env submodule
Yakir-Taboola Mar 3, 2025
5459043
Update create.sql
yakirgb Mar 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions go/base/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"

uuid "github.com/google/uuid"

Expand Down Expand Up @@ -237,6 +238,11 @@ type MigrationContext struct {
MigrationIterationRangeMaxValues *sql.ColumnValues
ForceTmpTableName string

IncludeTriggers bool
RemoveTriggerSuffix bool
TriggerSuffix string
Triggers []mysql.Trigger

recentBinlogCoordinates mysql.BinlogCoordinates

BinlogSyncerMaxReconnectAttempts int
Expand Down Expand Up @@ -924,3 +930,20 @@ func (this *MigrationContext) ReadConfigFile() error {

return nil
}

// getGhostTriggerName generates the name of a ghost trigger, based on original trigger name
// or a given trigger name
func (this *MigrationContext) GetGhostTriggerName(triggerName string) string {
if this.RemoveTriggerSuffix && strings.HasSuffix(triggerName, this.TriggerSuffix) {
return strings.TrimSuffix(triggerName, this.TriggerSuffix)
}
// else
return triggerName + this.TriggerSuffix
}

// validateGhostTriggerLength check if the ghost trigger name length is not more than 64 characters
func (this *MigrationContext) ValidateGhostTriggerLengthBelowMaxLength(triggerName string) bool {
ghostTriggerName := this.GetGhostTriggerName(triggerName)

return utf8.RuneCountInString(ghostTriggerName) <= mysql.MaxTableNameLength
}
63 changes: 63 additions & 0 deletions go/base/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package base

import (
"os"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -58,6 +59,68 @@ func TestGetTableNames(t *testing.T) {
}
}

func TestGetTriggerNames(t *testing.T) {
{
context := NewMigrationContext()
context.TriggerSuffix = "_gho"
require.Equal(t, "my_trigger"+context.TriggerSuffix, context.GetGhostTriggerName("my_trigger"))
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_gho"
context.RemoveTriggerSuffix = true
require.Equal(t, "my_trigger"+context.TriggerSuffix, context.GetGhostTriggerName("my_trigger"))
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_gho"
context.RemoveTriggerSuffix = true
require.Equal(t, "my_trigger", context.GetGhostTriggerName("my_trigger_gho"))
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_gho"
context.RemoveTriggerSuffix = false
require.Equal(t, "my_trigger_gho_gho", context.GetGhostTriggerName("my_trigger_gho"))
}
}

func TestValidateGhostTriggerLengthBelowMaxLength(t *testing.T) {
{
context := NewMigrationContext()
context.TriggerSuffix = "_gho"
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength("my_trigger"))
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost"
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 3))) // 48 characters + "_ghost"
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
context.RemoveTriggerSuffix = true
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost" removed
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
context.RemoveTriggerSuffix = true
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"X")) // 65 characters + "_ghost" not removed
}
{
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
context.RemoveTriggerSuffix = true
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"_ghost")) // 70 characters + last "_ghost" removed
}
}

func TestReadConfigFile(t *testing.T) {
{
context := NewMigrationContext()
Expand Down
20 changes: 19 additions & 1 deletion go/cmd/gh-ost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/url"
"os"
"os/signal"
"regexp"
"syscall"

"github.com/github/gh-ost/go/base"
Expand Down Expand Up @@ -137,6 +138,10 @@ func main() {
flag.UintVar(&migrationContext.ReplicaServerId, "replica-server-id", 99999, "server id used by gh-ost process. Default: 99999")
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")

flag.BoolVar(&migrationContext.IncludeTriggers, "include-triggers", false, "When true, the triggers (if exist) will be created on the new table")
flag.StringVar(&migrationContext.TriggerSuffix, "trigger-suffix", "", "Add a suffix to the trigger name (i.e '_v2'). Requires '--include-triggers'")
flag.BoolVar(&migrationContext.RemoveTriggerSuffix, "remove-trigger-suffix-if-exists", false, "Remove given suffix from name of trigger. Requires '--include-triggers' and '--trigger-suffix'")

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")
criticalLoad := flag.String("critical-load", "", "Comma delimited status-name=threshold, same format as --max-load. When status exceeds threshold, app panics and quits")
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")
Expand Down Expand Up @@ -257,7 +262,20 @@ func main() {
migrationContext.Log.Fatal("--ssl-allow-insecure requires --ssl")
}
if *replicationLagQuery != "" {
migrationContext.Log.Warning("--replication-lag-query is deprecated")
migrationContext.Log.Warningf("--replication-lag-query is deprecated")
}
if migrationContext.IncludeTriggers && migrationContext.TriggerSuffix == "" {
migrationContext.Log.Fatalf("--trigger-suffix must be used with --include-triggers")
}
if !migrationContext.IncludeTriggers && migrationContext.TriggerSuffix != "" {
migrationContext.Log.Fatalf("--trigger-suffix cannot be be used without --include-triggers")
}
if migrationContext.TriggerSuffix != "" {
regex := regexp.MustCompile(`^[\da-zA-Z_]+$`)

if !regex.Match([]byte(migrationContext.TriggerSuffix)) {
migrationContext.Log.Fatalf("--trigger-suffix must contain only alpha numeric characters and underscore (0-9,a-z,A-Z,_)")
}
}
if *storageEngine == "rocksdb" {
migrationContext.Log.Warning("RocksDB storage engine support is experimental")
Expand Down
50 changes: 50 additions & 0 deletions go/logic/applier.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,56 @@ func (this *Applier) dropTable(tableName string) error {
return nil
}

// dropTriggers drop the triggers on the applied host
func (this *Applier) DropTriggersFromGhost() error {
if len(this.migrationContext.Triggers) > 0 {
for _, trigger := range this.migrationContext.Triggers {
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
query := fmt.Sprintf("drop trigger if exists %s", sql.EscapeName(triggerName))
_, err := sqlutils.ExecNoPrepare(this.db, query)
if err != nil {
return err
}
this.migrationContext.Log.Infof("Trigger '%s' dropped", triggerName)
}
}
return nil
}

// createTriggers creates the triggers on the applied host
func (this *Applier) createTriggers(tableName string) error {
if len(this.migrationContext.Triggers) > 0 {
for _, trigger := range this.migrationContext.Triggers {
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
query := fmt.Sprintf(`create /* gh-ost */ trigger %s %s %s on %s.%s for each row
%s`,
sql.EscapeName(triggerName),
trigger.Timing,
trigger.Event,
sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(tableName),
trigger.Statement,
)
this.migrationContext.Log.Infof("Createing trigger %s on %s.%s",
sql.EscapeName(triggerName),
sql.EscapeName(this.migrationContext.DatabaseName),
sql.EscapeName(tableName),
)
if _, err := sqlutils.ExecNoPrepare(this.db, query); err != nil {
return err
}
}
this.migrationContext.Log.Infof("Triggers created on %s", tableName)
}
return nil
}

// CreateTriggers creates the original triggers on applier host
func (this *Applier) CreateTriggersOnGhost() error {
err := this.createTriggers(this.migrationContext.GetGhostTableName())
return err
}

// DropChangelogTable drops the changelog table on the applier host
func (this *Applier) DropChangelogTable() error {
return this.dropTable(this.migrationContext.GetChangelogTableName())
Expand Down
64 changes: 62 additions & 2 deletions go/logic/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ func (this *Inspector) validateTableForeignKeys(allowChildForeignKeys bool) erro
return nil
}

// validateTableTriggers makes sure no triggers exist on the migrated table
// validateTableTriggers makes sure no triggers exist on the migrated table. if --include_triggers is used then it fetches the triggers
func (this *Inspector) validateTableTriggers() error {
query := `
SELECT /* gh-ost */ COUNT(*) AS num_triggers
Expand All @@ -553,12 +553,72 @@ func (this *Inspector) validateTableTriggers() error {
return err
}
if numTriggers > 0 {
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))
if this.migrationContext.IncludeTriggers {
this.migrationContext.Log.Infof("Found %d triggers on %s.%s.", numTriggers, sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
this.migrationContext.Triggers, err = mysql.GetTriggers(this.db, this.migrationContext.DatabaseName, this.migrationContext.OriginalTableName)
if err != nil {
return err
}
if err := this.validateGhostTriggersDontExist(); err != nil {
return err
}
if err := this.validateGhostTriggersLength(); err != nil {
return err
}
return nil
}
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))
}
this.migrationContext.Log.Debugf("Validated no triggers exist on table")
return nil
}

// verifyTriggersDontExist verifies before createing new triggers we want to make sure these triggers dont exist already in the DB
func (this *Inspector) validateGhostTriggersDontExist() error {
if len(this.migrationContext.Triggers) > 0 {
var foundTriggers []string
for _, trigger := range this.migrationContext.Triggers {
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
query := "select 1 from information_schema.triggers where trigger_name = ? and trigger_schema = ? and event_object_table = ?"
err := sqlutils.QueryRowsMap(this.db, query, func(rowMap sqlutils.RowMap) error {
triggerExists := rowMap.GetInt("1")
if triggerExists == 1 {
foundTriggers = append(foundTriggers, triggerName)
}
return nil
},
triggerName,
this.migrationContext.DatabaseName,
this.migrationContext.OriginalTableName,
)
if err != nil {
return err
}
}
if len(foundTriggers) > 0 {
return this.migrationContext.Log.Errorf("Found gh-ost triggers (%s). Please use a different suffix or drop them. Bailing out", strings.Join(foundTriggers, ","))
}
}

return nil
}

func (this *Inspector) validateGhostTriggersLength() error {
if len(this.migrationContext.Triggers) > 0 {
var foundTriggers []string
for _, trigger := range this.migrationContext.Triggers {
triggerName := this.migrationContext.GetGhostTriggerName(trigger.Name)
if ok := this.migrationContext.ValidateGhostTriggerLengthBelowMaxLength(triggerName); !ok {
foundTriggers = append(foundTriggers, triggerName)
}
}
if len(foundTriggers) > 0 {
return this.migrationContext.Log.Errorf("Gh-ost triggers (%s) length > %d characters. Bailing out", strings.Join(foundTriggers, ","), mysql.MaxTableNameLength)
}
}
return nil
}

// estimateTableRowsViaExplain estimates number of rows on original table
func (this *Inspector) estimateTableRowsViaExplain() error {
query := fmt.Sprintf(`explain select /* gh-ost */ * from %s.%s where 1=1`, sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
Expand Down
13 changes: 13 additions & 0 deletions go/logic/migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,12 @@ func (this *Migrator) cutOverTwoStep() (err error) {
if err := this.retryOperation(this.waitForEventsUpToLock); err != nil {
return err
}
// If we need to create triggers we need to do it here (only create part)
if this.migrationContext.IncludeTriggers && len(this.migrationContext.Triggers) > 0 {
if err := this.retryOperation(this.applier.CreateTriggersOnGhost); err != nil {
return err
}
}
if err := this.retryOperation(this.applier.SwapTablesQuickAndBumpy); err != nil {
return err
}
Expand Down Expand Up @@ -676,6 +682,13 @@ func (this *Migrator) atomicCutOver() (err error) {
return this.migrationContext.Log.Errore(err)
}

// If we need to create triggers we need to do it here (only create part)
if this.migrationContext.IncludeTriggers && len(this.migrationContext.Triggers) > 0 {
if err := this.applier.CreateTriggersOnGhost(); err != nil {
this.migrationContext.Log.Errore(err)
}
}

// Step 2
// We now attempt an atomic RENAME on original & ghost tables, and expect it to block.
this.migrationContext.RenameTablesStartTime = time.Now()
Expand Down
28 changes: 28 additions & 0 deletions go/mysql/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ type ReplicationLagResult struct {
Err error
}

type Trigger struct {
Name string
Event string
Statement string
Timing string
}

func NewNoReplicationLagResult() *ReplicationLagResult {
return &ReplicationLagResult{Lag: 0, Err: nil}
}
Expand Down Expand Up @@ -224,3 +231,24 @@ func Kill(db *gosql.DB, connectionID string) error {
_, err := db.Exec(`KILL QUERY %s`, connectionID)
return err
}

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

err = sqlutils.QueryRowsMap(db, query, func(rowMap sqlutils.RowMap) error {
triggers = append(triggers, Trigger{
Name: rowMap.GetString("name"),
Event: rowMap.GetString("event"),
Statement: rowMap.GetString("statement"),
Timing: rowMap.GetString("timing"),
})
return nil
})
if err != nil {
return nil, err
}
return triggers, nil
}
Loading
Loading