Skip to content

Commit bb70d4d

Browse files
authored
add online_schema_migrations metric (#4497)
emit metric for gh-ost MySQL migrations to track it's usage
1 parent 256631d commit bb70d4d

4 files changed

Lines changed: 235 additions & 2 deletions

File tree

flow/connectors/mysql/cdc.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const (
5050
queryStatusVarSQLMode = 1
5151
)
5252

53+
const (
54+
OnlineSchemaMigrationToolGhOst = "gh-ost"
55+
OnlineSchemaMigrationToolPtOsc = "pt-online-schema-change"
56+
OnlineSchemaMigrationToolOther = "other"
57+
)
58+
5359
// sqlModeFromStatusVars extracts the sql_mode bitmask from a QueryEvent's status vars.
5460
// Both MySQL and MariaDB guarantee status vars are written in increasing order,
5561
// so we only need to parse 0 and 1 to get to 1
@@ -586,12 +592,15 @@ func (c *MySqlConnector) PullRecords(
586592
c.logger.Warn("processing QueryEvent with logged warnings", slog.Any("warns", warns))
587593
}
588594
for _, stmt := range stmts {
589-
if alterTableStmt, ok := stmt.(*ast.AlterTableStmt); ok {
595+
switch s := stmt.(type) {
596+
case *ast.AlterTableStmt:
590597
if err := c.processAlterTableQuery(
591-
ctx, catalogPool, otelManager, req, alterTableStmt,
598+
ctx, catalogPool, otelManager, req, s,
592599
string(ev.Schema), binlogRowMetadataSupported, req.InternalVersion); err != nil {
593600
return fmt.Errorf("failed to process ALTER TABLE query: %w", err)
594601
}
602+
case *ast.RenameTableStmt:
603+
c.processRenameTableQuery(ctx, otelManager, req, s, string(ev.Schema))
595604
}
596605
}
597606
case *replication.RowsEvent:
@@ -1025,6 +1034,72 @@ func (c *MySqlConnector) processAlterTableQuery(ctx context.Context, catalogPool
10251034
return nil
10261035
}
10271036

1037+
// processRenameTableQuery detects online schema-migration tools (gh-ost,
1038+
// pt-online-schema-change, ...). These never issue an ALTER on the tracked
1039+
// table; instead they build a shadow/ghost copy with the new schema and
1040+
// atomically rename it into place, e.g.:
1041+
//
1042+
// RENAME TABLE users TO _users_del, _users_gho TO users
1043+
//
1044+
// The resulting schema change surfaces to us only later, as row-event metadata
1045+
// (see processTableMapEventSchema). Here we just meter how often a tracked table
1046+
// is renamed-into, so we can gauge how prevalent these tools are
1047+
func (c *MySqlConnector) processRenameTableQuery(
1048+
ctx context.Context,
1049+
otelManager *otel_metrics.OtelManager,
1050+
req *model.PullRecordsRequest[model.RecordItems],
1051+
stmt *ast.RenameTableStmt,
1052+
stmtSchema string,
1053+
) {
1054+
for _, t2t := range stmt.TableToTables {
1055+
if t2t.NewTable == nil || t2t.OldTable == nil {
1056+
continue
1057+
}
1058+
1059+
// if the rename target has no schema, fall back to the one on the event
1060+
newSchemaName := t2t.NewTable.Schema.String()
1061+
if newSchemaName == "" {
1062+
newSchemaName = stmtSchema
1063+
}
1064+
newTableName := newSchemaName + "." + t2t.NewTable.Name.String()
1065+
1066+
// only care about renames that land on a table we are replicating
1067+
if _, tracked := req.TableNameMapping[newTableName]; !tracked {
1068+
continue
1069+
}
1070+
1071+
oldTableName := t2t.OldTable.Name.String()
1072+
tool := classifyOnlineSchemaMigrationTool(oldTableName, t2t.NewTable.Name.String())
1073+
1074+
c.logger.Info("table atomically renamed into a tracked table, likely an online schema migration",
1075+
slog.String("table", newTableName),
1076+
slog.String("renamedFrom", oldTableName),
1077+
slog.String("tool", tool))
1078+
1079+
otelManager.Metrics.OnlineSchemaMigrationsCounter.Add(ctx, 1, metric.WithAttributeSet(attribute.NewSet(
1080+
attribute.String(otel_metrics.SourcePeerType, "mysql"),
1081+
attribute.String(otel_metrics.OnlineSchemaMigrationTool, tool),
1082+
)))
1083+
}
1084+
}
1085+
1086+
// classifyOnlineSchemaMigrationTool guesses which online schema-change tool
1087+
// performed a rename, based on the shadow table's naming convention:
1088+
// - gh-ost ghost table: _<table>_gho
1089+
// - pt-online-schema-change new table: _<table>_new
1090+
//
1091+
// Anything else that renames into a tracked table is bucketed as "other".
1092+
func classifyOnlineSchemaMigrationTool(oldTable, newTable string) string {
1093+
switch oldTable {
1094+
case "_" + newTable + "_gho":
1095+
return OnlineSchemaMigrationToolGhOst
1096+
case "_" + newTable + "_new":
1097+
return OnlineSchemaMigrationToolPtOsc
1098+
default:
1099+
return OnlineSchemaMigrationToolOther
1100+
}
1101+
}
1102+
10281103
func posToOffsetText(pos mysql.Position) string {
10291104
return fmt.Sprintf("!f:%s,%x", pos.Name, pos.Pos)
10301105
}

flow/connectors/mysql/cdc_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package connmysql
33
import (
44
"context"
55
"fmt"
6+
"log/slog"
67
"slices"
78
"strings"
89
"testing"
@@ -14,9 +15,15 @@ import (
1415
"github.com/pingcap/tidb/pkg/parser/ast"
1516
tidbmysql "github.com/pingcap/tidb/pkg/parser/mysql"
1617
"github.com/stretchr/testify/require"
18+
"go.opentelemetry.io/otel/metric"
19+
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
20+
"go.opentelemetry.io/otel/sdk/metric/metricdata"
21+
"go.temporal.io/sdk/log"
1722

1823
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1924
"github.com/PeerDB-io/peerdb/flow/internal"
25+
"github.com/PeerDB-io/peerdb/flow/model"
26+
"github.com/PeerDB-io/peerdb/flow/otel_metrics"
2027
"github.com/PeerDB-io/peerdb/flow/shared"
2128
)
2229

@@ -155,6 +162,145 @@ func TestParseSQLParsesTrailingNull(t *testing.T) {
155162
}
156163
}
157164

165+
func TestClassifyOnlineSchemaMigrationTool(t *testing.T) {
166+
for _, tc := range []struct {
167+
name string
168+
oldTable string
169+
newTable string
170+
want string
171+
}{
172+
{"gh-ost", "_users_gho", "users", OnlineSchemaMigrationToolGhOst},
173+
{"pt-osc", "_users_new", "users", OnlineSchemaMigrationToolPtOsc},
174+
{"plain rename", "users_backup", "users", OnlineSchemaMigrationToolOther},
175+
{"gh-ost del table is not the target", "_users_del", "users", OnlineSchemaMigrationToolOther},
176+
} {
177+
t.Run(tc.name, func(t *testing.T) {
178+
require.Equal(t, tc.want, classifyOnlineSchemaMigrationTool(tc.oldTable, tc.newTable))
179+
})
180+
}
181+
}
182+
183+
func TestParseSQLParsesGhOstRename(t *testing.T) {
184+
mysqlParser := parser.New()
185+
// the atomic swap gh-ost issues at the end of a migration
186+
query := []byte("RENAME TABLE `mydb`.`users` TO `mydb`.`_users_del`, `mydb`.`_users_gho` TO `mydb`.`users`")
187+
stmts, warns, err := parseSQL(mysqlParser, query)
188+
require.NoError(t, err)
189+
require.Empty(t, warns)
190+
require.Len(t, stmts, 1)
191+
192+
rename, ok := stmts[0].(*ast.RenameTableStmt)
193+
require.True(t, ok)
194+
require.Len(t, rename.TableToTables, 2)
195+
196+
// the rename that lands on the tracked table is the ghost table swap
197+
swap := rename.TableToTables[1]
198+
require.Equal(t, "users", swap.NewTable.Name.String())
199+
require.Equal(t, "_users_gho", swap.OldTable.Name.String())
200+
require.Equal(t, OnlineSchemaMigrationToolGhOst,
201+
classifyOnlineSchemaMigrationTool(swap.OldTable.Name.String(), swap.NewTable.Name.String()))
202+
}
203+
204+
// newMetricsTestOtelManager builds an OtelManager backed by a ManualReader so tests
205+
// can collect and assert recorded metric values without a real exporter.
206+
func newMetricsTestOtelManager(t *testing.T) (*otel_metrics.OtelManager, *sdkmetric.ManualReader) {
207+
t.Helper()
208+
reader := sdkmetric.NewManualReader()
209+
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
210+
om := &otel_metrics.OtelManager{
211+
MetricsProvider: provider,
212+
Meter: provider.Meter("test"),
213+
Int64CountersCache: make(map[string]metric.Int64Counter),
214+
}
215+
counter, err := om.GetOrInitInt64Counter(otel_metrics.BuildMetricName(otel_metrics.OnlineSchemaMigrationsName))
216+
require.NoError(t, err)
217+
om.Metrics.OnlineSchemaMigrationsCounter = counter
218+
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
219+
return om, reader
220+
}
221+
222+
// collectOnlineSchemaMigrationCounts collects the online_schema_migrations counter and
223+
// returns its value bucketed by the `tool` attribute.
224+
func collectOnlineSchemaMigrationCounts(t *testing.T, ctx context.Context, reader *sdkmetric.ManualReader) map[string]int64 {
225+
t.Helper()
226+
var rm metricdata.ResourceMetrics
227+
require.NoError(t, reader.Collect(ctx, &rm))
228+
229+
wantName := otel_metrics.BuildMetricName(otel_metrics.OnlineSchemaMigrationsName)
230+
counts := make(map[string]int64)
231+
for _, sm := range rm.ScopeMetrics {
232+
for _, m := range sm.Metrics {
233+
if m.Name != wantName {
234+
continue
235+
}
236+
sum, ok := m.Data.(metricdata.Sum[int64])
237+
require.True(t, ok, "expected Int64 sum for %s", wantName)
238+
for _, dp := range sum.DataPoints {
239+
tool, _ := dp.Attributes.Value(otel_metrics.OnlineSchemaMigrationTool)
240+
source, _ := dp.Attributes.Value(otel_metrics.SourcePeerType)
241+
require.Equal(t, "mysql", source.AsString())
242+
counts[tool.AsString()] += dp.Value
243+
}
244+
}
245+
}
246+
return counts
247+
}
248+
249+
func TestProcessRenameTableQueryMetric(t *testing.T) {
250+
ctx := t.Context()
251+
c := &MySqlConnector{logger: log.NewStructuredLogger(slog.Default())}
252+
253+
parseRename := func(query string) *ast.RenameTableStmt {
254+
t.Helper()
255+
stmts, _, err := parseSQL(parser.New(), []byte(query))
256+
require.NoError(t, err)
257+
require.Len(t, stmts, 1)
258+
rename, ok := stmts[0].(*ast.RenameTableStmt)
259+
require.True(t, ok)
260+
return rename
261+
}
262+
263+
for _, tc := range []struct {
264+
name string
265+
query string
266+
want map[string]int64
267+
}{
268+
{
269+
name: "gh-ost cutover on tracked table increments once",
270+
// full gh-ost atomic swap: only the `_users_gho -> users` pair lands on a tracked table
271+
query: "RENAME TABLE `mydb`.`users` TO `mydb`.`_users_del`, `mydb`.`_users_gho` TO `mydb`.`users`",
272+
want: map[string]int64{OnlineSchemaMigrationToolGhOst: 1},
273+
},
274+
{
275+
name: "pt-online-schema-change cutover",
276+
query: "RENAME TABLE `mydb`.`users` TO `mydb`.`_users_old`, `mydb`.`_users_new` TO `mydb`.`users`",
277+
want: map[string]int64{OnlineSchemaMigrationToolPtOsc: 1},
278+
},
279+
{
280+
name: "schema falls back to event schema",
281+
// no schema qualifier on the statement; resolved from the event's schema
282+
query: "RENAME TABLE `users` TO `_users_del`, `_users_gho` TO `users`",
283+
want: map[string]int64{OnlineSchemaMigrationToolGhOst: 1},
284+
},
285+
{
286+
name: "rename into untracked table is ignored",
287+
// neither target is a table we replicate
288+
query: "RENAME TABLE `mydb`.`orders` TO `mydb`.`orders_archive`",
289+
want: map[string]int64{},
290+
},
291+
} {
292+
t.Run(tc.name, func(t *testing.T) {
293+
om, reader := newMetricsTestOtelManager(t)
294+
req := &model.PullRecordsRequest[model.RecordItems]{
295+
TableNameMapping: map[string]model.NameAndExclude{"mydb.users": {Name: "users_dst"}},
296+
}
297+
c.processRenameTableQuery(ctx, om, req, parseRename(tc.query), "mydb")
298+
299+
require.Equal(t, tc.want, collectOnlineSchemaMigrationCounts(t, ctx, reader))
300+
})
301+
}
302+
}
303+
158304
func TestGetTableSchemaCaseSensitiveIdentifiers(t *testing.T) {
159305
t.Parallel()
160306
ctx := t.Context()

flow/otel_metrics/attributes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const (
4949
TypeChangeFromKey = "from"
5050
TypeChangeToKey = "to"
5151
SourceEventTypeKey = "sourceEventType"
52+
OnlineSchemaMigrationTool = "tool"
5253
)
5354

5455
const (

flow/otel_metrics/otel_manager.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const (
8282
ServerWalEndLagGaugeName = "wal_end_lag"
8383
UsedMySQLCharsetsName = "used_mysql_charsets"
8484
ColumnTypeChangesName = "column_type_changes"
85+
OnlineSchemaMigrationsName = "online_schema_migrations"
8586
)
8687

8788
type Metrics struct {
@@ -138,6 +139,7 @@ type Metrics struct {
138139
ServerWalEndLagGauge metric.Int64Gauge
139140
UsedMySQLCharsetsCounter metric.Int64Counter
140141
ColumnTypeChangesCounter metric.Int64Counter
142+
OnlineSchemaMigrationsCounter metric.Int64Counter
141143
}
142144

143145
type SlotMetricGauges struct {
@@ -592,6 +594,15 @@ func (om *OtelManager) setupMetrics(ctx context.Context) error {
592594
return err
593595
}
594596

597+
if om.Metrics.OnlineSchemaMigrationsCounter, err = om.GetOrInitInt64Counter(BuildMetricName(OnlineSchemaMigrationsName),
598+
metric.WithDescription(
599+
"Counter of online schema migrations detected on the CDC path, i.e. a tracked table being atomically "+
600+
"renamed into by a shadow/ghost table, with `source` label holding the source peer type and `tool` "+
601+
"label holding the detected migration tool (gh-ost, pt-online-schema-change, other)"),
602+
); err != nil {
603+
return err
604+
}
605+
595606
if CodeNotificationCounter, err = om.GetOrInitInt64Counter(BuildMetricName(CodeNotificationCounterName),
596607
metric.WithDescription("One-off notifications with unique `message` attribute, triggers generic non-paging alert"),
597608
); err != nil {

0 commit comments

Comments
 (0)