| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package mysqlfunc |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "context" |
| 8 | "database/sql" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "regexp" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | mysqlDriver "github.com/go-sql-driver/mysql" |
| 18 | |
| 19 | "github.com/netdata/netdata/go/plugins/pkg/funcapi" |
| 20 | "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/strmutil" |
| 21 | ) |
| 22 | |
| 23 | const deadlockInfoMethodID = "deadlock-info" |
| 24 | const queryShowEngineInnoDBStatus = "SHOW ENGINE INNODB STATUS;" |
| 25 | |
| 26 | const ( |
| 27 | deadlockSectionWaiting = "waiting" |
| 28 | deadlockSectionHolds = "holds" |
| 29 | ) |
| 30 | |
| 31 | var ( |
| 32 | reDeadlockHeader = regexp.MustCompile(`LATEST DETECTED DEADLOCK`) |
| 33 | reDeadlockTxn = regexp.MustCompile(`(?i)^\*\*\* \((\d+)\) TRANSACTION:?`) |
| 34 | reDeadlockWait = regexp.MustCompile(`(?i)^\*\*\* \((\d+)\) WAITING FOR THIS LOCK TO BE GRANTED:?`) |
| 35 | reDeadlockHolds = regexp.MustCompile(`(?i)^\*\*\* \((\d+)\) HOLDS THE LOCK\(S\):?`) |
| 36 | reDeadlockWaitNoTxn = regexp.MustCompile(`(?i)^\*\*\*\s*WAITING FOR THIS LOCK TO BE GRANTED:?`) |
| 37 | reDeadlockHoldsNoTxn = regexp.MustCompile(`(?i)^\*\*\*\s*HOLDS THE LOCK\(S\):?`) |
| 38 | reDeadlockVictim = regexp.MustCompile(`(?i)^\*\*\* WE ROLL BACK TRANSACTION \((\d+)\)`) |
| 39 | reDeadlockThread = regexp.MustCompile(`(?i)\b(?:mysql|mariadb)?\s*thread id\s+(\d+)`) |
| 40 | reDeadlockMode = regexp.MustCompile(`(?i)lock[_ ]mode\s+([A-Z0-9_-]+)`) |
| 41 | reDeadlockTS = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\b`) |
| 42 | reDeadlockTable = regexp.MustCompile(`(?i)\bof\s+table\s+` + "`?" + `([-\w$]+)` + "`?" + `\.` + "`?" + `([-\w$]+)` + "`?") |
| 43 | reQueryTableRef = regexp.MustCompile(`(?i)\b(?:from|update|into|join)\s+` + "`?" + `([-\w$]+)` + "`?" + `\.` + "`?" + `([-\w$]+)` + "`?") |
| 44 | reSQLStatement = regexp.MustCompile(`(?i)^(?:/\*.*\*/\s*)?(SELECT|UPDATE|INSERT|DELETE|REPLACE|WITH|ALTER|CREATE|DROP|TRUNCATE|LOCK|UNLOCK|SET|SHOW|CALL|EXEC|EXECUTE|DO|BEGIN|COMMIT|ROLLBACK|MERGE)\b`) |
| 45 | ) |
| 46 | |
| 47 | const ( |
| 48 | deadlockInfoHelp = "Latest detected deadlock from SHOW ENGINE INNODB STATUS. WARNING: query text may include unmasked sensitive literals; restrict dashboard access." |
| 49 | deadlockParseErrorStatus = 561 |
| 50 | ) |
| 51 | |
| 52 | // deadlockRowData holds computed values for a single deadlock row. |
| 53 | type deadlockRowData struct { |
| 54 | rowID string |
| 55 | deadlockID string |
| 56 | timestamp string |
| 57 | processID string |
| 58 | spid any |
| 59 | isVictim string |
| 60 | queryText string |
| 61 | lockMode string |
| 62 | lockStatus string |
| 63 | waitResource string |
| 64 | database any |
| 65 | } |
| 66 | |
| 67 | // deadlockColumn defines a column for the deadlock-info function. |
| 68 | type deadlockColumn struct { |
| 69 | funcapi.ColumnMeta |
| 70 | Value func(*deadlockRowData) any |
| 71 | } |
| 72 | |
| 73 | func deadlockColumnSet(cols []deadlockColumn) funcapi.ColumnSet[deadlockColumn] { |
| 74 | return funcapi.Columns(cols, func(c deadlockColumn) funcapi.ColumnMeta { return c.ColumnMeta }) |
| 75 | } |
| 76 | |
| 77 | var deadlockColumns = []deadlockColumn{ |
| 78 | { |
| 79 | ColumnMeta: funcapi.ColumnMeta{ |
| 80 | Name: "row_id", |
| 81 | Tooltip: "Unique identifier for this row", |
| 82 | Type: funcapi.FieldTypeString, |
| 83 | Sort: funcapi.FieldSortAscending, |
| 84 | Sortable: true, |
| 85 | Summary: funcapi.FieldSummaryCount, |
| 86 | Filter: funcapi.FieldFilterMultiselect, |
| 87 | UniqueKey: true, |
| 88 | Visible: false, |
| 89 | }, |
| 90 | Value: func(r *deadlockRowData) any { return r.rowID }, |
| 91 | }, |
| 92 | { |
| 93 | ColumnMeta: funcapi.ColumnMeta{ |
| 94 | Name: "timestamp", |
| 95 | Tooltip: "When the deadlock occurred", |
| 96 | Type: funcapi.FieldTypeTimestamp, |
| 97 | Sort: funcapi.FieldSortDescending, |
| 98 | Sortable: true, |
| 99 | Summary: funcapi.FieldSummaryMax, |
| 100 | Filter: funcapi.FieldFilterRange, |
| 101 | Visible: true, |
| 102 | Transform: funcapi.FieldTransformDatetime, |
| 103 | }, |
| 104 | Value: func(r *deadlockRowData) any { return r.timestamp }, |
| 105 | }, |
| 106 | { |
| 107 | ColumnMeta: funcapi.ColumnMeta{ |
| 108 | Name: "is_victim", |
| 109 | Tooltip: "Whether this transaction was rolled back to resolve the deadlock", |
| 110 | Type: funcapi.FieldTypeString, |
| 111 | Visualization: funcapi.FieldVisualPill, |
| 112 | Sort: funcapi.FieldSortAscending, |
| 113 | Sortable: true, |
| 114 | Summary: funcapi.FieldSummaryCount, |
| 115 | Filter: funcapi.FieldFilterMultiselect, |
| 116 | Visible: true, |
| 117 | }, |
| 118 | Value: func(r *deadlockRowData) any { return r.isVictim }, |
| 119 | }, |
| 120 | { |
| 121 | ColumnMeta: funcapi.ColumnMeta{ |
| 122 | Name: "query_text", |
| 123 | Tooltip: "The SQL statement being executed", |
| 124 | Type: funcapi.FieldTypeString, |
| 125 | Sort: funcapi.FieldSortAscending, |
| 126 | Sortable: false, |
| 127 | Sticky: true, |
| 128 | Summary: funcapi.FieldSummaryCount, |
| 129 | Filter: funcapi.FieldFilterMultiselect, |
| 130 | FullWidth: true, |
| 131 | Wrap: true, |
| 132 | Visible: true, |
| 133 | }, |
| 134 | Value: func(r *deadlockRowData) any { return r.queryText }, |
| 135 | }, |
| 136 | { |
| 137 | ColumnMeta: funcapi.ColumnMeta{ |
| 138 | Name: "database", |
| 139 | Tooltip: "Database where the deadlock occurred", |
| 140 | Type: funcapi.FieldTypeString, |
| 141 | Sort: funcapi.FieldSortAscending, |
| 142 | Sortable: true, |
| 143 | Summary: funcapi.FieldSummaryCount, |
| 144 | Filter: funcapi.FieldFilterMultiselect, |
| 145 | Visible: true, |
| 146 | }, |
| 147 | Value: func(r *deadlockRowData) any { return r.database }, |
| 148 | }, |
| 149 | { |
| 150 | ColumnMeta: funcapi.ColumnMeta{ |
| 151 | Name: "lock_mode", |
| 152 | Tooltip: "Type of lock (S=Shared, X=Exclusive, IS/IX=Intent locks)", |
| 153 | Type: funcapi.FieldTypeString, |
| 154 | Sort: funcapi.FieldSortAscending, |
| 155 | Sortable: true, |
| 156 | Summary: funcapi.FieldSummaryCount, |
| 157 | Filter: funcapi.FieldFilterMultiselect, |
| 158 | Visible: true, |
| 159 | }, |
| 160 | Value: func(r *deadlockRowData) any { return r.lockMode }, |
| 161 | }, |
| 162 | { |
| 163 | ColumnMeta: funcapi.ColumnMeta{ |
| 164 | Name: "lock_status", |
| 165 | Tooltip: "Whether the lock was granted or still waiting", |
| 166 | Type: funcapi.FieldTypeString, |
| 167 | Visualization: funcapi.FieldVisualPill, |
| 168 | Sort: funcapi.FieldSortAscending, |
| 169 | Sortable: true, |
| 170 | Summary: funcapi.FieldSummaryCount, |
| 171 | Filter: funcapi.FieldFilterMultiselect, |
| 172 | Visible: true, |
| 173 | }, |
| 174 | Value: func(r *deadlockRowData) any { return r.lockStatus }, |
| 175 | }, |
| 176 | { |
| 177 | ColumnMeta: funcapi.ColumnMeta{ |
| 178 | Name: "wait_resource", |
| 179 | Tooltip: "The resource this transaction was waiting to acquire", |
| 180 | Type: funcapi.FieldTypeString, |
| 181 | Sort: funcapi.FieldSortAscending, |
| 182 | Sortable: false, |
| 183 | Summary: funcapi.FieldSummaryCount, |
| 184 | Filter: funcapi.FieldFilterMultiselect, |
| 185 | Visible: true, |
| 186 | }, |
| 187 | Value: func(r *deadlockRowData) any { return r.waitResource }, |
| 188 | }, |
| 189 | { |
| 190 | ColumnMeta: funcapi.ColumnMeta{ |
| 191 | Name: "spid", |
| 192 | Tooltip: "MySQL thread/connection ID", |
| 193 | Type: funcapi.FieldTypeInteger, |
| 194 | Sort: funcapi.FieldSortAscending, |
| 195 | Sortable: true, |
| 196 | Summary: funcapi.FieldSummaryCount, |
| 197 | Filter: funcapi.FieldFilterRange, |
| 198 | Visible: true, |
| 199 | Transform: funcapi.FieldTransformNumber, |
| 200 | }, |
| 201 | Value: func(r *deadlockRowData) any { return r.spid }, |
| 202 | }, |
| 203 | { |
| 204 | ColumnMeta: funcapi.ColumnMeta{ |
| 205 | Name: "process_id", |
| 206 | Tooltip: "Transaction identifier from InnoDB", |
| 207 | Type: funcapi.FieldTypeString, |
| 208 | Sort: funcapi.FieldSortAscending, |
| 209 | Sortable: true, |
| 210 | Summary: funcapi.FieldSummaryCount, |
| 211 | Filter: funcapi.FieldFilterMultiselect, |
| 212 | Visible: true, |
| 213 | }, |
| 214 | Value: func(r *deadlockRowData) any { return r.processID }, |
| 215 | }, |
| 216 | { |
| 217 | ColumnMeta: funcapi.ColumnMeta{ |
| 218 | Name: "deadlock_id", |
| 219 | Tooltip: "Unique identifier for this deadlock event", |
| 220 | Type: funcapi.FieldTypeString, |
| 221 | Sort: funcapi.FieldSortAscending, |
| 222 | Sortable: true, |
| 223 | Summary: funcapi.FieldSummaryCount, |
| 224 | Filter: funcapi.FieldFilterMultiselect, |
| 225 | Visible: true, |
| 226 | }, |
| 227 | Value: func(r *deadlockRowData) any { return r.deadlockID }, |
| 228 | }, |
| 229 | { |
| 230 | ColumnMeta: funcapi.ColumnMeta{ |
| 231 | Name: "ecid", |
| 232 | Tooltip: "Execution Context ID (SQL Server concept, not used in MySQL)", |
| 233 | Type: funcapi.FieldTypeInteger, |
| 234 | Sort: funcapi.FieldSortAscending, |
| 235 | Sortable: true, |
| 236 | Summary: funcapi.FieldSummaryCount, |
| 237 | Filter: funcapi.FieldFilterRange, |
| 238 | Visible: false, // SQL Server concept, not applicable to MySQL/MariaDB |
| 239 | Transform: funcapi.FieldTransformNumber, |
| 240 | }, |
| 241 | Value: func(r *deadlockRowData) any { return nil }, |
| 242 | }, |
| 243 | } |
| 244 | |
| 245 | func deadlockInfoMethodConfig() funcapi.MethodConfig { |
| 246 | return funcapi.MethodConfig{ |
| 247 | ID: deadlockInfoMethodID, |
| 248 | Name: "Deadlock Info", |
| 249 | UpdateEvery: 10, |
| 250 | Help: deadlockInfoHelp, |
| 251 | RequireCloud: true, |
| 252 | RequiredParams: []funcapi.ParamConfig{}, |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // funcDeadlockInfo handles the deadlock-info function. |
| 257 | type funcDeadlockInfo struct { |
| 258 | router *router |
| 259 | } |
| 260 | |
| 261 | func newFuncDeadlockInfo(r *router) *funcDeadlockInfo { |
| 262 | return &funcDeadlockInfo{router: r} |
| 263 | } |
| 264 | |
| 265 | // Compile-time interface check. |
| 266 | var _ funcapi.MethodHandler = (*funcDeadlockInfo)(nil) |
| 267 | |
| 268 | func (f *funcDeadlockInfo) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) { |
| 269 | if f.router.cfg.deadlockInfoDisabled() { |
| 270 | return nil, fmt.Errorf("deadlock-info function disabled in configuration") |
| 271 | } |
| 272 | return []funcapi.ParamConfig{}, nil |
| 273 | } |
| 274 | |
| 275 | func (f *funcDeadlockInfo) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse { |
| 276 | if _, err := f.router.deps.DB(); err != nil { |
| 277 | return funcapi.UnavailableResponse("collector is still initializing, please retry in a few seconds") |
| 278 | } |
| 279 | queryCtx, cancel := context.WithTimeout(ctx, f.router.cfg.deadlockInfoTimeout()) |
| 280 | defer cancel() |
| 281 | return f.collectData(queryCtx) |
| 282 | } |
| 283 | |
| 284 | func (f *funcDeadlockInfo) Cleanup(ctx context.Context) {} |
| 285 | |
| 286 | func (f *funcDeadlockInfo) collectData(ctx context.Context) *funcapi.FunctionResponse { |
| 287 | if f.router.cfg.deadlockInfoDisabled() { |
| 288 | return funcapi.UnavailableResponse("deadlock-info function has been disabled in configuration") |
| 289 | } |
| 290 | |
| 291 | statusText, err := f.queryInnoDBStatus(ctx) |
| 292 | if err != nil { |
| 293 | if errors.Is(err, context.DeadlineExceeded) { |
| 294 | return f.buildResponse(504, "deadlock query timed out", nil) |
| 295 | } |
| 296 | if isMySQLPermissionError(err) { |
| 297 | return f.buildResponse( |
| 298 | 403, |
| 299 | "Deadlock info requires permission to run SHOW ENGINE INNODB STATUS. "+ |
| 300 | "Grant with: GRANT USAGE, REPLICATION CLIENT, PROCESS ON *.* TO 'netdata'@'%';", |
| 301 | nil, |
| 302 | ) |
| 303 | } |
| 304 | f.router.log.Warningf("deadlock-info: query failed: %v", err) |
| 305 | return f.buildResponse(500, fmt.Sprintf("deadlock query failed: %v", err), nil) |
| 306 | } |
| 307 | |
| 308 | parseRes := parseInnoDBDeadlock(statusText, time.Now().UTC()) |
| 309 | if parseRes.parseErr != nil { |
| 310 | f.router.log.Warningf("deadlock-info: parse failed: %v", parseRes.parseErr) |
| 311 | return f.buildResponse(deadlockParseErrorStatus, "deadlock section could not be parsed", nil) |
| 312 | } |
| 313 | if !parseRes.found { |
| 314 | return f.buildResponse(200, "no deadlock found in SHOW ENGINE INNODB STATUS", nil) |
| 315 | } |
| 316 | |
| 317 | deadlockID := generateDeadlockID(parseRes.deadlockTime) |
| 318 | rows := buildDeadlockRows(parseRes, deadlockID) |
| 319 | if len(rows) == 0 { |
| 320 | return f.buildResponse(200, "deadlock detected but no transactions could be parsed", nil) |
| 321 | } |
| 322 | |
| 323 | return f.buildResponse(200, "latest detected deadlock", rows) |
| 324 | } |
| 325 | |
| 326 | func (f *funcDeadlockInfo) buildResponse(status int, message string, rowsData []deadlockRowData) *funcapi.FunctionResponse { |
| 327 | data := make([][]any, 0, len(rowsData)) |
| 328 | for i := range rowsData { |
| 329 | row := make([]any, len(deadlockColumns)) |
| 330 | for j, col := range deadlockColumns { |
| 331 | row[j] = col.Value(&rowsData[i]) |
| 332 | } |
| 333 | data = append(data, row) |
| 334 | } |
| 335 | |
| 336 | cs := deadlockColumnSet(deadlockColumns) |
| 337 | |
| 338 | return &funcapi.FunctionResponse{ |
| 339 | Status: status, |
| 340 | Help: deadlockInfoHelp, |
| 341 | Message: message, |
| 342 | Columns: cs.BuildColumns(), |
| 343 | Data: data, |
| 344 | DefaultSortColumn: "timestamp", |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | func (f *funcDeadlockInfo) queryInnoDBStatus(ctx context.Context) (string, error) { |
| 349 | qctx, cancel := context.WithTimeout(ctx, f.router.cfg.collectorTimeout()) |
| 350 | defer cancel() |
| 351 | |
| 352 | db, err := f.router.deps.DB() |
| 353 | if err != nil { |
| 354 | return "", err |
| 355 | } |
| 356 | |
| 357 | var typ, name, status sql.NullString |
| 358 | row := db.QueryRowContext(qctx, queryShowEngineInnoDBStatus) |
| 359 | if err := row.Scan(&typ, &name, &status); err != nil { |
| 360 | return "", err |
| 361 | } |
| 362 | if !status.Valid { |
| 363 | return "", fmt.Errorf("innodb status response was empty") |
| 364 | } |
| 365 | return status.String, nil |
| 366 | } |
| 367 | |
| 368 | type mysqlDeadlockTxn struct { |
| 369 | txnNum int |
| 370 | threadID string |
| 371 | queryText string |
| 372 | lockMode string |
| 373 | lockStatus string |
| 374 | waitResource string |
| 375 | } |
| 376 | |
| 377 | type mysqlDeadlockParseResult struct { |
| 378 | found bool |
| 379 | deadlockTime time.Time |
| 380 | victimTxnNum int |
| 381 | transactions []*mysqlDeadlockTxn |
| 382 | parseErr error |
| 383 | } |
| 384 | |
| 385 | func parseInnoDBDeadlock(status string, now time.Time) mysqlDeadlockParseResult { |
| 386 | result := mysqlDeadlockParseResult{ |
| 387 | found: false, |
| 388 | deadlockTime: now.UTC(), |
| 389 | } |
| 390 | |
| 391 | section, ok := extractDeadlockSection(status) |
| 392 | if !ok { |
| 393 | return result |
| 394 | } |
| 395 | result.found = true |
| 396 | |
| 397 | if ts, ok := parseDeadlockTimestamp(section); ok { |
| 398 | result.deadlockTime = ts.UTC() |
| 399 | } |
| 400 | |
| 401 | scanner := bufio.NewScanner(strings.NewReader(section)) |
| 402 | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| 403 | |
| 404 | txnByNum := make(map[int]*mysqlDeadlockTxn) |
| 405 | txnOrder := make([]int, 0, 4) |
| 406 | |
| 407 | currentTxnNum := 0 |
| 408 | currentSection := "" |
| 409 | expectingQueryTxn := 0 |
| 410 | victimTxnNum := 0 |
| 411 | |
| 412 | ensureTxn := func(num int) *mysqlDeadlockTxn { |
| 413 | if txn, ok := txnByNum[num]; ok { |
| 414 | return txn |
| 415 | } |
| 416 | txn := &mysqlDeadlockTxn{txnNum: num} |
| 417 | txnByNum[num] = txn |
| 418 | txnOrder = append(txnOrder, num) |
| 419 | return txn |
| 420 | } |
| 421 | |
| 422 | for scanner.Scan() { |
| 423 | line := strings.TrimSpace(scanner.Text()) |
| 424 | if line == "" { |
| 425 | continue |
| 426 | } |
| 427 | |
| 428 | if num, ok := parseDeadlockTxnHeader(line); ok { |
| 429 | currentTxnNum = num |
| 430 | currentSection = "" |
| 431 | expectingQueryTxn = 0 |
| 432 | ensureTxn(num) |
| 433 | continue |
| 434 | } |
| 435 | |
| 436 | if num, sectionType, ok := parseDeadlockTxnSection(line); ok { |
| 437 | currentTxnNum = num |
| 438 | currentSection = sectionType |
| 439 | expectingQueryTxn = 0 |
| 440 | ensureTxn(num) |
| 441 | continue |
| 442 | } |
| 443 | |
| 444 | if currentTxnNum != 0 { |
| 445 | if isDeadlockWaitNoTxn(line) { |
| 446 | currentSection = deadlockSectionWaiting |
| 447 | expectingQueryTxn = 0 |
| 448 | ensureTxn(currentTxnNum) |
| 449 | continue |
| 450 | } |
| 451 | if isDeadlockHoldsNoTxn(line) { |
| 452 | currentSection = deadlockSectionHolds |
| 453 | expectingQueryTxn = 0 |
| 454 | ensureTxn(currentTxnNum) |
| 455 | continue |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | if num, ok := parseDeadlockVictim(line); ok { |
| 460 | victimTxnNum = num |
| 461 | continue |
| 462 | } |
| 463 | |
| 464 | if currentTxnNum == 0 { |
| 465 | continue |
| 466 | } |
| 467 | |
| 468 | txn := ensureTxn(currentTxnNum) |
| 469 | |
| 470 | if threadID, ok := parseDeadlockThreadID(line); ok { |
| 471 | txn.threadID = threadID |
| 472 | expectingQueryTxn = currentTxnNum |
| 473 | continue |
| 474 | } |
| 475 | |
| 476 | if expectingQueryTxn == currentTxnNum && txn.queryText == "" && isSQLStatementLine(line) { |
| 477 | txn.queryText = line |
| 478 | expectingQueryTxn = 0 |
| 479 | continue |
| 480 | } |
| 481 | |
| 482 | if expectingQueryTxn == 0 && txn.queryText == "" && isSQLStatementLine(line) { |
| 483 | txn.queryText = line |
| 484 | continue |
| 485 | } |
| 486 | |
| 487 | switch currentSection { |
| 488 | case deadlockSectionWaiting: |
| 489 | // WAITING must win even if HOLDS was seen first in the output. |
| 490 | txn.lockStatus = "WAITING" |
| 491 | if txn.waitResource == "" && isLockResourceLine(line) { |
| 492 | txn.waitResource = strmutil.TruncateText(line, topQueriesMaxTextLength) |
| 493 | } |
| 494 | if mode, ok := parseDeadlockLockMode(line); ok { |
| 495 | // WAITING lock mode should override any mode captured from HOLDS. |
| 496 | txn.lockMode = mode |
| 497 | } |
| 498 | case deadlockSectionHolds: |
| 499 | if txn.lockStatus == "" { |
| 500 | txn.lockStatus = "GRANTED" |
| 501 | } |
| 502 | if txn.lockMode == "" { |
| 503 | if mode, ok := parseDeadlockLockMode(line); ok { |
| 504 | txn.lockMode = mode |
| 505 | } |
| 506 | } |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | if err := scanner.Err(); err != nil { |
| 511 | result.parseErr = err |
| 512 | return result |
| 513 | } |
| 514 | |
| 515 | result.victimTxnNum = victimTxnNum |
| 516 | result.transactions = make([]*mysqlDeadlockTxn, 0, len(txnOrder)) |
| 517 | for _, num := range txnOrder { |
| 518 | txn := txnByNum[num] |
| 519 | if txn == nil { |
| 520 | continue |
| 521 | } |
| 522 | if txn.threadID == "" { |
| 523 | txn.threadID = fmt.Sprintf("txn-%d", num) |
| 524 | } |
| 525 | if txn.lockStatus == "" { |
| 526 | if num == victimTxnNum { |
| 527 | txn.lockStatus = "WAITING" |
| 528 | } else { |
| 529 | txn.lockStatus = "GRANTED" |
| 530 | } |
| 531 | } |
| 532 | result.transactions = append(result.transactions, txn) |
| 533 | } |
| 534 | |
| 535 | if len(result.transactions) == 0 { |
| 536 | result.parseErr = fmt.Errorf("deadlock section detected but no transactions could be parsed") |
| 537 | return result |
| 538 | } |
| 539 | |
| 540 | sort.Slice(result.transactions, func(i, j int) bool { |
| 541 | return result.transactions[i].txnNum < result.transactions[j].txnNum |
| 542 | }) |
| 543 | |
| 544 | return result |
| 545 | } |
| 546 | |
| 547 | func buildDeadlockRows(parseRes mysqlDeadlockParseResult, deadlockID string) []deadlockRowData { |
| 548 | rows := make([]deadlockRowData, 0, len(parseRes.transactions)) |
| 549 | timestamp := parseRes.deadlockTime.UTC().Format(time.RFC3339Nano) |
| 550 | |
| 551 | for _, txn := range parseRes.transactions { |
| 552 | if txn == nil { |
| 553 | continue |
| 554 | } |
| 555 | |
| 556 | processID := strings.TrimSpace(txn.threadID) |
| 557 | if processID == "" { |
| 558 | processID = fmt.Sprintf("txn-%d", txn.txnNum) |
| 559 | } |
| 560 | |
| 561 | var spid any |
| 562 | if id, err := strconv.Atoi(processID); err == nil { |
| 563 | spid = id |
| 564 | } else { |
| 565 | spid = nil |
| 566 | } |
| 567 | |
| 568 | isVictim := "false" |
| 569 | if parseRes.victimTxnNum != 0 && txn.txnNum == parseRes.victimTxnNum { |
| 570 | isVictim = "true" |
| 571 | } |
| 572 | |
| 573 | queryText := strmutil.TruncateText(strings.TrimSpace(txn.queryText), topQueriesMaxTextLength) |
| 574 | lockMode := formatLockMode(strings.TrimSpace(txn.lockMode)) |
| 575 | lockStatus := strings.TrimSpace(txn.lockStatus) |
| 576 | waitResource := strmutil.TruncateText(strings.TrimSpace(txn.waitResource), topQueriesMaxTextLength) |
| 577 | database := extractDeadlockDatabase(waitResource, queryText) |
| 578 | |
| 579 | var databaseValue any |
| 580 | if database != "" { |
| 581 | databaseValue = database |
| 582 | } |
| 583 | |
| 584 | rows = append(rows, deadlockRowData{ |
| 585 | rowID: fmt.Sprintf("%s:%s", deadlockID, processID), |
| 586 | deadlockID: deadlockID, |
| 587 | timestamp: timestamp, |
| 588 | processID: processID, |
| 589 | spid: spid, |
| 590 | isVictim: isVictim, |
| 591 | queryText: queryText, |
| 592 | lockMode: lockMode, |
| 593 | lockStatus: lockStatus, |
| 594 | waitResource: waitResource, |
| 595 | database: databaseValue, |
| 596 | }) |
| 597 | } |
| 598 | |
| 599 | return rows |
| 600 | } |
| 601 | |
| 602 | func extractDeadlockSection(status string) (string, bool) { |
| 603 | idx := reDeadlockHeader.FindStringIndex(status) |
| 604 | if idx == nil { |
| 605 | return "", false |
| 606 | } |
| 607 | return status[idx[0]:], true |
| 608 | } |
| 609 | |
| 610 | func parseDeadlockTimestamp(section string) (time.Time, bool) { |
| 611 | match := reDeadlockTS.FindString(section) |
| 612 | if match == "" { |
| 613 | return time.Time{}, false |
| 614 | } |
| 615 | ts, err := time.ParseInLocation("2006-01-02 15:04:05", match, time.Local) |
| 616 | if err != nil { |
| 617 | return time.Time{}, false |
| 618 | } |
| 619 | return ts, true |
| 620 | } |
| 621 | |
| 622 | func parseDeadlockTxnHeader(line string) (int, bool) { |
| 623 | m := reDeadlockTxn.FindStringSubmatch(line) |
| 624 | if len(m) != 2 { |
| 625 | return 0, false |
| 626 | } |
| 627 | n, err := strconv.Atoi(m[1]) |
| 628 | if err != nil { |
| 629 | return 0, false |
| 630 | } |
| 631 | return n, true |
| 632 | } |
| 633 | |
| 634 | func parseDeadlockTxnSection(line string) (int, string, bool) { |
| 635 | if m := reDeadlockWait.FindStringSubmatch(line); len(m) == 2 { |
| 636 | n, err := strconv.Atoi(m[1]) |
| 637 | if err != nil { |
| 638 | return 0, "", false |
| 639 | } |
| 640 | return n, deadlockSectionWaiting, true |
| 641 | } |
| 642 | if m := reDeadlockHolds.FindStringSubmatch(line); len(m) == 2 { |
| 643 | n, err := strconv.Atoi(m[1]) |
| 644 | if err != nil { |
| 645 | return 0, "", false |
| 646 | } |
| 647 | return n, deadlockSectionHolds, true |
| 648 | } |
| 649 | return 0, "", false |
| 650 | } |
| 651 | |
| 652 | func isDeadlockWaitNoTxn(line string) bool { |
| 653 | return reDeadlockWaitNoTxn.MatchString(line) |
| 654 | } |
| 655 | |
| 656 | func isDeadlockHoldsNoTxn(line string) bool { |
| 657 | return reDeadlockHoldsNoTxn.MatchString(line) |
| 658 | } |
| 659 | |
| 660 | func parseDeadlockVictim(line string) (int, bool) { |
| 661 | m := reDeadlockVictim.FindStringSubmatch(line) |
| 662 | if len(m) != 2 { |
| 663 | return 0, false |
| 664 | } |
| 665 | n, err := strconv.Atoi(m[1]) |
| 666 | if err != nil { |
| 667 | return 0, false |
| 668 | } |
| 669 | return n, true |
| 670 | } |
| 671 | |
| 672 | func parseDeadlockThreadID(line string) (string, bool) { |
| 673 | m := reDeadlockThread.FindStringSubmatch(line) |
| 674 | if len(m) != 2 { |
| 675 | return "", false |
| 676 | } |
| 677 | return m[1], true |
| 678 | } |
| 679 | |
| 680 | func parseDeadlockLockMode(line string) (string, bool) { |
| 681 | m := reDeadlockMode.FindStringSubmatch(line) |
| 682 | if len(m) != 2 { |
| 683 | return "", false |
| 684 | } |
| 685 | return strings.ToUpper(m[1]), true |
| 686 | } |
| 687 | |
| 688 | func isLikelyQueryLine(line string) bool { |
| 689 | return isSQLStatementLine(line) |
| 690 | } |
| 691 | |
| 692 | func isSQLStatementLine(line string) bool { |
| 693 | trimmed := strings.TrimSpace(line) |
| 694 | if trimmed == "" { |
| 695 | return false |
| 696 | } |
| 697 | upper := strings.ToUpper(trimmed) |
| 698 | if strings.HasPrefix(upper, "LOCK WAIT") { |
| 699 | return false |
| 700 | } |
| 701 | return reSQLStatement.MatchString(trimmed) |
| 702 | } |
| 703 | |
| 704 | func isLockResourceLine(line string) bool { |
| 705 | upper := strings.ToUpper(strings.TrimSpace(line)) |
| 706 | return strings.HasPrefix(upper, "RECORD LOCKS") || strings.HasPrefix(upper, "TABLE LOCK") |
| 707 | } |
| 708 | |
| 709 | func extractDeadlockDatabase(waitResource, queryText string) string { |
| 710 | if db := extractDatabaseFromLock(waitResource); db != "" { |
| 711 | return db |
| 712 | } |
| 713 | if db := extractDatabaseFromQuery(queryText); db != "" { |
| 714 | return db |
| 715 | } |
| 716 | return "" |
| 717 | } |
| 718 | |
| 719 | func extractDatabaseFromLock(line string) string { |
| 720 | m := reDeadlockTable.FindStringSubmatch(line) |
| 721 | if len(m) >= 2 { |
| 722 | return m[1] |
| 723 | } |
| 724 | return "" |
| 725 | } |
| 726 | |
| 727 | func extractDatabaseFromQuery(queryText string) string { |
| 728 | m := reQueryTableRef.FindStringSubmatch(queryText) |
| 729 | if len(m) >= 2 { |
| 730 | return m[1] |
| 731 | } |
| 732 | return "" |
| 733 | } |
| 734 | |
| 735 | func generateDeadlockID(t time.Time) string { |
| 736 | if t.IsZero() { |
| 737 | t = time.Now().UTC() |
| 738 | } |
| 739 | t = t.UTC() |
| 740 | micros := t.Nanosecond() / 1000 |
| 741 | return t.Format("20060102150405") + fmt.Sprintf("%06d", micros) |
| 742 | } |
| 743 | |
| 744 | func isMySQLPermissionError(err error) bool { |
| 745 | var mysqlErr *mysqlDriver.MySQLError |
| 746 | if errors.As(err, &mysqlErr) { |
| 747 | if mysqlErr.Number == 1045 || mysqlErr.Number == 1227 { |
| 748 | return true |
| 749 | } |
| 750 | } |
| 751 | msg := strings.ToLower(err.Error()) |
| 752 | return strings.Contains(msg, "access denied") || |
| 753 | strings.Contains(msg, "permission denied") || |
| 754 | strings.Contains(msg, "process privilege") |
| 755 | } |
| 756 | |
| 757 | // formatLockMode converts InnoDB lock mode abbreviations to human-readable format. |
| 758 | func formatLockMode(mode string) string { |
| 759 | names := map[string]string{ |
| 760 | "X": "Exclusive", |
| 761 | "S": "Shared", |
| 762 | "IX": "Intent Exclusive", |
| 763 | "IS": "Intent Shared", |
| 764 | } |
| 765 | if name, ok := names[mode]; ok { |
| 766 | return fmt.Sprintf("%s (%s)", name, mode) |
| 767 | } |
| 768 | return mode |
| 769 | } |