Skip to content

Commit 764acd8

Browse files
dtunikovclaudeilidemi
authored
feat(mysql): add counter metric for parseSQL errors (#4498)
## Summary Adds a counter metric tracking failures parsing MySQL `QueryEvent` SQL on the CDC path. - New `parse_sql_errors` Int64 counter in `otel_metrics` - Incremented in the `parseSQL` error branch in `flow/connectors/mysql/cdc.go` - Classify known error patterns and avoid reporting failures for such Also partially resolves DBI-860. The counter is intentionally **label-free** to avoid high cardinality from the varied SQL queries / error messages that can fail to parse. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ilia Demianenko <ilia.demianenko@clickhouse.com>
1 parent 4418fd3 commit 764acd8

4 files changed

Lines changed: 496 additions & 7 deletions

File tree

flow/connectors/mysql/cdc.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ func (c *MySqlConnector) PullRecords(
454454
return err
455455
}
456456

457+
isMariaDb := c.Flavor() == mysql.MariaDBFlavor
457458
binlogRowMetadataSupported, err := c.IsBinlogRowMetadataSupported(ctx)
458459
if err != nil {
459460
return fmt.Errorf("failed to determine if binlog row metadata is supported: %w", err)
@@ -596,22 +597,36 @@ func (c *MySqlConnector) PullRecords(
596597
setParserSQLModeFromStatusVars(mysqlParser, ev.StatusVars)
597598
stmts, warns, err := parseSQL(mysqlParser, ev.Query)
598599
if err != nil {
599-
c.logger.Warn("failed to parse QueryEvent", slog.String("query", string(ev.Query)), slog.Any("error", err))
600+
if classifyUnparsedStatement(string(ev.Query), isMariaDb) == ddlKindIgnored {
601+
c.logger.Warn("skipping parse failure for non-replicated statement",
602+
slog.String("query", string(ev.Query)), slog.Any("error", err))
603+
} else {
604+
c.logger.Error("failed to parse QueryEvent", slog.String("query", string(ev.Query)), slog.Any("error", err))
605+
otelManager.Metrics.ParseSQLErrorsCounter.Add(ctx, 1)
606+
}
600607
break
601608
}
602609
if len(warns) > 0 {
603-
c.logger.Warn("processing QueryEvent with logged warnings", slog.Any("warns", warns))
610+
warnStrs := make([]string, len(warns))
611+
for i, w := range warns {
612+
warnStrs[i] = w.Error()
613+
}
614+
c.logger.Warn("processing QueryEvent with logged warnings", slog.Any("warns", warnStrs))
604615
}
605616
for _, stmt := range stmts {
606-
switch s := stmt.(type) {
607-
case *ast.AlterTableStmt:
617+
kind, alterStmt, renameStmt := classifyParsedStatement(stmt)
618+
switch kind {
619+
case ddlKindAlterTable:
608620
if err := c.processAlterTableQuery(
609-
ctx, catalogPool, otelManager, req, s,
621+
ctx, catalogPool, otelManager, req, alterStmt,
610622
string(ev.Schema), binlogRowMetadataSupported, req.InternalVersion); err != nil {
611623
return fmt.Errorf("failed to process ALTER TABLE query: %w", err)
612624
}
613-
case *ast.RenameTableStmt:
614-
c.processRenameTableQuery(ctx, otelManager, req, s, string(ev.Schema))
625+
case ddlKindRenameTable:
626+
c.processRenameTableQuery(ctx, otelManager, req, renameStmt, string(ev.Schema))
627+
case ddlKindIgnored:
628+
default:
629+
return fmt.Errorf("unknown stmt kind: %v", kind)
615630
}
616631
}
617632
case *replication.RowsEvent:
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
package connmysql
2+
3+
import (
4+
"strings"
5+
6+
"github.com/pingcap/tidb/pkg/parser/ast"
7+
)
8+
9+
func isStatementKeywordByte(c byte) bool {
10+
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
11+
}
12+
13+
// leadingKeywords returns up to limit uppercased word tokens from the start of a
14+
// SQL statement, skipping leading/inline whitespace and -- , # and /* */ comments.
15+
// It only has to look at the head of the statement, so it never scans a whole
16+
// procedure body. Used to classify statements that failed to parse, where the AST
17+
// is unavailable and only the raw query text remains.
18+
//
19+
// Executable comments are not comments: the server runs their contents, so they are
20+
// lexed as code rather than skipped. /*! ... */ executes on both MySQL and MariaDB;
21+
// /*M! ... */ executes only on MariaDB (it is a plain comment on MySQL), so it is
22+
// lexed only when isMariaDb. The optional version digits (e.g. /*!80000) are not
23+
// compared against a server version — the body is always lexed when the marker
24+
// matches, which at worst surfaces a benign statement as reportable rather than
25+
// hiding a real one.
26+
func leadingKeywords(query string, limit int, isMariaDb bool) []string {
27+
out := make([]string, 0, limit)
28+
for i, n := 0, len(query); i < n && len(out) < limit; {
29+
switch c := query[i]; {
30+
case c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v':
31+
i++
32+
case c == '/' && i+1 < n && query[i+1] == '*':
33+
if m := executableCommentBody(query, i, isMariaDb); m > 0 {
34+
// Skip only the opening marker (and version digits); lex the body as
35+
// code. The closing */ is consumed later as punctuation by default.
36+
i = m
37+
continue
38+
}
39+
end := strings.Index(query[i+2:], "*/")
40+
if end < 0 {
41+
return out
42+
}
43+
i += 2 + end + 2
44+
case (c == '-' && i+1 < n && query[i+1] == '-') || c == '#':
45+
// a line comment runs to the next line break, either \n or a bare \r
46+
nl := strings.IndexAny(query[i:], "\r\n")
47+
if nl < 0 {
48+
return out
49+
}
50+
i += nl + 1
51+
case c == '`' || c == '\'' || c == '"':
52+
i = skipQuoted(query, i)
53+
case isStatementKeywordByte(c):
54+
j := i + 1
55+
for j < n && isStatementKeywordByte(query[j]) {
56+
j++
57+
}
58+
out = append(out, strings.ToUpper(query[i:j]))
59+
i = j
60+
default:
61+
// punctuation/operators (=, (, @, %, ...) — not part of a keyword
62+
i++
63+
}
64+
}
65+
return out
66+
}
67+
68+
// skipQuoted consumes a backtick-quoted identifier or a single/double-quoted string
69+
// literal starting at query[i] (which must be the opening quote), returning the index
70+
// just past the closing quote, or len(query) if it is unterminated.
71+
func skipQuoted(query string, i int) int {
72+
q := query[i]
73+
backslashEscapes := q != '`'
74+
for j, n := i+1, len(query); j < n; j++ {
75+
switch query[j] {
76+
case '\\':
77+
if backslashEscapes {
78+
j++ // skip the escaped byte
79+
}
80+
case q:
81+
if j+1 < n && query[j+1] == q {
82+
j++ // doubled quote is an escaped quote; stay inside the span
83+
continue
84+
}
85+
return j + 1
86+
}
87+
}
88+
return len(query)
89+
}
90+
91+
// executableCommentBody reports the index at which an executable comment's body
92+
// begins when query[i:] opens one (i points at the leading '/'), or 0 otherwise.
93+
// /*! ... */ is executable everywhere; /*M! ... */ is executable only on MariaDB.
94+
// Any version digits following the marker (e.g. /*!80000) are skipped, not compared.
95+
func executableCommentBody(query string, i int, isMariaDb bool) int {
96+
n := len(query)
97+
var body int
98+
switch {
99+
case i+2 < n && query[i+2] == '!':
100+
body = i + 3
101+
case isMariaDb && i+3 < n && query[i+2] == 'M' && query[i+3] == '!':
102+
body = i + 4
103+
default:
104+
return 0
105+
}
106+
for body < n && query[body] >= '0' && query[body] <= '9' {
107+
body++
108+
}
109+
return body
110+
}
111+
112+
// objectKeywords are the keywords naming what a CREATE/ALTER/DROP/RENAME statement
113+
// operates on. The first one to appear identifies the object kind; it precedes any
114+
// routine/trigger/view body and any IGNORE/ONLINE/DEFINER/ALGORITHM modifiers, so
115+
// scanning for it never trips over a body that happens to mention TABLE.
116+
var objectKeywords = map[string]struct{}{
117+
"TABLE": {}, "TABLES": {}, "DATABASE": {}, "SCHEMA": {}, "INDEX": {}, "VIEW": {},
118+
"PROCEDURE": {}, "FUNCTION": {}, "TRIGGER": {}, "EVENT": {}, "USER": {},
119+
"TABLESPACE": {}, "SEQUENCE": {}, "SERVER": {}, "ROLE": {}, "LOGFILE": {},
120+
}
121+
122+
// stripSetStatementPrefix removes a leading MariaDB/RDS "SET STATEMENT var=value[, ...]
123+
// FOR" wrapper, returning the keywords of the statement it wraps. The TiDB parser
124+
// rejects the whole "SET STATEMENT ... FOR <statement>" form, so without unwrapping it
125+
// the inner statement is never classified and a real ALTER/RENAME TABLE hidden behind
126+
// it (e.g. "SET STATEMENT max_statement_time=60 FOR ALTER TABLE t ADD COLUMN c INT")
127+
// would go unreported.
128+
//
129+
// A plain "SET ..." (without STATEMENT) wraps no statement and is left untouched. If no
130+
// FOR is found the input is returned as-is, so it falls through to ddlKindIgnored.
131+
func stripSetStatementPrefix(kw []string) []string {
132+
if len(kw) < 2 || kw[0] != "SET" || kw[1] != "STATEMENT" {
133+
return kw
134+
}
135+
for i := 2; i < len(kw); i++ {
136+
if kw[i] == "FOR" {
137+
return kw[i+1:]
138+
}
139+
}
140+
return kw
141+
}
142+
143+
func firstObjectKeyword(kw []string) string {
144+
for _, w := range kw {
145+
if _, ok := objectKeywords[w]; ok {
146+
return w
147+
}
148+
}
149+
return ""
150+
}
151+
152+
// indexConstraintKeywords are the keywords that, immediately following ADD or DROP
153+
// in an ALTER TABLE spec, name an index, key, or constraint rather than a column,
154+
// e.g. ADD UNIQUE INDEX, ADD FULLTEXT KEY, ADD PRIMARY KEY, ADD CONSTRAINT,
155+
// ADD FOREIGN KEY, DROP INDEX, DROP PRIMARY KEY.
156+
var indexConstraintKeywords = map[string]struct{}{
157+
"INDEX": {}, "KEY": {}, "UNIQUE": {}, "FULLTEXT": {}, "SPATIAL": {},
158+
"PRIMARY": {}, "FOREIGN": {}, "CONSTRAINT": {}, "CHECK": {},
159+
}
160+
161+
// isIndexOnlyTableAlteration reports whether an ALTER TABLE statement only adds,
162+
// drops, renames, or toggles indexes/keys/constraints, never touching columns —
163+
// none of which processAlterTableQuery acts on.
164+
//
165+
// It requires at least one index/key/constraint operation and refuses as soon as it
166+
// sees any column operation, so a mixed statement like
167+
//
168+
// ALTER TABLE t ADD c INT, ADD INDEX i (c)
169+
//
170+
// (which would silently lose the added column) is still reported.
171+
func isIndexOnlyTableAlteration(kw []string) bool {
172+
sawIndexOp := false
173+
for i, w := range kw {
174+
next := ""
175+
if i+1 < len(kw) {
176+
next = kw[i+1]
177+
}
178+
switch w {
179+
case "MODIFY", "CHANGE":
180+
// always column operations (MODIFY/CHANGE [COLUMN] ...)
181+
return false
182+
case "COLUMN":
183+
// ALTER COLUMN / RENAME COLUMN — column ops whose verb isn't matched
184+
// above. (ADD/DROP COLUMN are handled by the ADD/DROP case; the COLUMN
185+
// keyword there is optional, so those bare forms land there too.)
186+
// e.g. "ALTER TABLE t ADD INDEX i (a), ALTER COLUMN b SET DEFAULT 1"
187+
return false
188+
case "ADD", "DROP":
189+
if _, ok := indexConstraintKeywords[next]; ok {
190+
sawIndexOp = true
191+
} else {
192+
// ADD/DROP of a column, with or without the optional COLUMN keyword
193+
return false
194+
}
195+
case "RENAME":
196+
// RENAME {INDEX|KEY} ... TO ... is an index op. RENAME COLUMN is caught by
197+
// the COLUMN case, and RENAME TO <table> (a table rename) is left to the
198+
// default and reported.
199+
if next == "INDEX" || next == "KEY" {
200+
sawIndexOp = true
201+
}
202+
case "ALTER":
203+
// ALTER INDEX ... [IN]VISIBLE toggles index visibility. The leading
204+
// "ALTER TABLE" has next == "TABLE", and ALTER COLUMN is caught by the
205+
// COLUMN case, so neither is misread as an index op here.
206+
if next == "INDEX" {
207+
sawIndexOp = true
208+
}
209+
case "DISABLE", "ENABLE":
210+
// {DISABLE|ENABLE} KEYS toggles index maintenance.
211+
if next == "KEYS" {
212+
sawIndexOp = true
213+
}
214+
}
215+
}
216+
return sawIndexOp
217+
}
218+
219+
type ddlKind int
220+
221+
const (
222+
ddlKindIgnored ddlKind = iota
223+
ddlKindAlterTable
224+
ddlKindRenameTable
225+
)
226+
227+
// classifyParsedStatement maps a successfully parsed statement to the handler that
228+
// acts on it, returning the typed node for that handler (nil for the others). Every
229+
// other statement — CREATE/DROP TABLE, DATABASE / SCHEMA / INDEX / VIEW /
230+
// stored-routine / trigger / event / user DDL, SET, XA, GRANT/REVOKE, etc. — is
231+
// ddlKindIgnored even when it parses cleanly.
232+
func classifyParsedStatement(stmt ast.StmtNode) (ddlKind, *ast.AlterTableStmt, *ast.RenameTableStmt) {
233+
switch s := stmt.(type) {
234+
case *ast.AlterTableStmt:
235+
return ddlKindAlterTable, s, nil
236+
case *ast.RenameTableStmt:
237+
return ddlKindRenameTable, nil, s
238+
default:
239+
return ddlKindIgnored, nil, nil
240+
}
241+
}
242+
243+
// classifyUnparsedStatement classifies a QueryEvent that failed to parse using only
244+
// its leading keywords, the AST being unavailable. It mirrors classifyParsedStatement:
245+
// ddlKindIgnored means PeerDB would not have acted on the statement even had it parsed,
246+
// so the parse failure is benign noise; any other kind means a statement we care about
247+
// failed to parse and the failure should be reported.
248+
//
249+
// The TiDB parser routinely rejects statements RDS/MariaDB emit constantly (e.g.
250+
// SET STATEMENT ... FOR ... heartbeats, stored-routine bodies, MariaDB-only DDL),
251+
// which are the bulk of the noise this drops.
252+
func classifyUnparsedStatement(query string, isMariaDb bool) ddlKind {
253+
kw := stripSetStatementPrefix(leadingKeywords(query, 100, isMariaDb))
254+
if len(kw) == 0 {
255+
return ddlKindIgnored
256+
}
257+
258+
switch kw[0] {
259+
case "ALTER":
260+
if firstObjectKeyword(kw[1:]) != "TABLE" {
261+
return ddlKindIgnored
262+
}
263+
// An ALTER TABLE that only manipulates indexes/keys/constraints is a no-op for
264+
// processAlterTableQuery, so its parse failure is benign too.
265+
if isIndexOnlyTableAlteration(kw) {
266+
return ddlKindIgnored
267+
}
268+
return ddlKindAlterTable
269+
case "RENAME":
270+
// MariaDB also allows the plural "RENAME TABLES ...".
271+
objectKeyword := firstObjectKeyword(kw[1:])
272+
if objectKeyword == "TABLE" || (isMariaDb && objectKeyword == "TABLES") {
273+
return ddlKindRenameTable
274+
}
275+
return ddlKindIgnored
276+
default:
277+
// CREATE/DROP (of any object, including TABLE) and everything else are not processed.
278+
return ddlKindIgnored
279+
}
280+
}

0 commit comments

Comments
 (0)