master
sh 977 lines 28.5 KB
Raw
1 #!/usr/bin/env bash
2 set -euo pipefail
3
4 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5 # shellcheck disable=SC1091
6 . "$SCRIPT_DIR/lib.sh"
7
8 init_workspace "mysql"
9 trap cleanup EXIT
10
11 MYSQL_PORT="$(reserve_port)"
12 write_env "MYSQL_PORT" "$MYSQL_PORT"
13 replace_in_file "$WORKDIR/config/go.d/mysql.conf" "127.0.0.1:3306" "127.0.0.1:${MYSQL_PORT}"
14
15 MYSQL_VARIANT_LABEL="${MYSQL_VARIANT:-mysql}"
16 if [ -n "${MYSQL_IMAGE:-}" ]; then
17 write_env "MYSQL_IMAGE" "$MYSQL_IMAGE"
18 fi
19
20 compose_up mysql
21 MYSQL_HEALTH_TIMEOUT="${MYSQL_HEALTH_TIMEOUT:-180}"
22 wait_healthy mysql "$MYSQL_HEALTH_TIMEOUT"
23
24 build_plugin
25 run_info mysql
26 run_top_queries mysql
27
28 assert_top_queries_error_columns() {
29 local input="$1"
30 if command -v python3 >/dev/null 2>&1; then
31 python3 - "$input" <<'PY'
32 import io
33 import json
34 import sys
35
36 path = sys.argv[1]
37 with io.open(path, "r", encoding="utf-8") as fh:
38 doc = json.load(fh)
39 columns = doc.get("columns") or {}
40 required = {"errorAttribution", "errorNumber", "sqlState", "errorMessage"}
41
42 found = set()
43 if isinstance(columns, dict):
44 for key in columns.keys():
45 found.add(key)
46 else:
47 for col in columns:
48 if isinstance(col, dict):
49 field = col.get("field")
50 if field:
51 found.add(field)
52
53 missing = sorted(required - found)
54 if missing:
55 raise SystemExit("missing top-queries error columns: {}".format(missing))
56 PY
57 return
58 fi
59 python - "$input" <<'PY'
60 import io
61 import json
62 import sys
63
64 path = sys.argv[1]
65 with open(path, "r") as fh:
66 doc = json.load(fh)
67 columns = doc.get("columns") or {}
68 required = {"errorAttribution", "errorNumber", "sqlState", "errorMessage"}
69
70 found = set()
71 if isinstance(columns, dict):
72 for key in columns.keys():
73 found.add(key)
74 else:
75 for col in columns:
76 if isinstance(col, dict):
77 field = col.get("field")
78 if field:
79 found.add(field)
80
81 missing = sorted(required - found)
82 if missing:
83 raise SystemExit("missing top-queries error columns: %s" % missing)
84 PY
85 }
86
87 assert_top_queries_error_columns "$WORKDIR/mysql-top-queries.json"
88
89 mysql_container_id() {
90 "${COMPOSE[@]}" ps -q mysql
91 }
92
93 mysql_client_path() {
94 local cid
95 cid="$(mysql_container_id)"
96 if [ -z "$cid" ]; then
97 echo "MySQL container ID not found" >&2
98 return 1
99 fi
100 docker exec -i "$cid" sh -lc 'command -v mysql || command -v mariadb'
101 }
102
103 MYSQL_CLIENT_PATH="$(mysql_client_path)"
104 echo "Using mysql client: $MYSQL_CLIENT_PATH" >&2
105
106 mysql_exec_root() {
107 local sql="$1"
108 local cid
109 cid="$(mysql_container_id)"
110 if [ -z "$cid" ]; then
111 echo "MySQL container ID not found" >&2
112 return 1
113 fi
114 run docker exec -i "$cid" "$MYSQL_CLIENT_PATH" -uroot -prootpw netdata -e "$sql"
115 }
116
117 mysql_query_root() {
118 local sql="$1"
119 local cid
120 cid="$(mysql_container_id)"
121 if [ -z "$cid" ]; then
122 echo "MySQL container ID not found" >&2
123 return 1
124 fi
125 run docker exec -i "$cid" "$MYSQL_CLIENT_PATH" -uroot -prootpw -N -s netdata -e "$sql"
126 }
127
128 mysql_exec_root_allow_error() {
129 local sql="$1"
130 local cid
131 cid="$(mysql_container_id)"
132 if [ -z "$cid" ]; then
133 echo "MySQL container ID not found" >&2
134 return 1
135 fi
136 set +e
137 docker exec -i "$cid" "$MYSQL_CLIENT_PATH" -uroot -prootpw netdata -e "$sql" >/dev/null 2>&1
138 set -e
139 }
140
141 induce_deadlock_once() {
142 local tx1
143 local tx2
144
145 tx1="$(cat <<'SQL'
146 SET SESSION innodb_lock_wait_timeout = 5;
147 START TRANSACTION;
148 UPDATE deadlock_a SET value = value + 1 WHERE id = 1;
149 DO SLEEP(1);
150 UPDATE deadlock_b SET value = value + 1 WHERE id = 1;
151 COMMIT;
152 SQL
153 )"
154
155 tx2="$(cat <<'SQL'
156 SET SESSION innodb_lock_wait_timeout = 5;
157 START TRANSACTION;
158 UPDATE deadlock_b SET value = value + 1 WHERE id = 1;
159 DO SLEEP(1);
160 UPDATE deadlock_a SET value = value + 1 WHERE id = 1;
161 COMMIT;
162 SQL
163 )"
164
165 mysql_exec_root "$tx1" &
166 local pid1=$!
167 mysql_exec_root "$tx2" &
168 local pid2=$!
169
170 wait "$pid1" || true
171 wait "$pid2" || true
172 }
173
174 assert_deadlock_info_content() {
175 local input="$1"
176 if command -v python3 >/dev/null 2>&1; then
177 python3 - "$input" <<'PY'
178 import io
179 import json
180 import re
181 import sys
182
183 path = sys.argv[1]
184 with io.open(path, "r", encoding="utf-8") as fh:
185 doc = json.load(fh)
186
187 try:
188 status = int(doc.get("status"))
189 except (TypeError, ValueError):
190 raise SystemExit("unexpected status value: {!r}".format(doc.get("status")))
191
192 if status != 200:
193 raise SystemExit("expected status 200, got {}".format(status))
194
195 if doc.get("errorMessage"):
196 raise SystemExit("unexpected errorMessage on status 200: {!r}".format(doc.get("errorMessage")))
197
198 columns = doc.get("columns") or {}
199 field_to_idx = {}
200 if isinstance(columns, dict):
201 for field, col in columns.items():
202 if not isinstance(col, dict):
203 continue
204 try:
205 field_to_idx[field] = int(col.get("index"))
206 except (TypeError, ValueError):
207 continue
208 else:
209 for idx, col in enumerate(columns):
210 if not isinstance(col, dict):
211 continue
212 field = col.get("field")
213 if field:
214 field_to_idx[field] = idx
215
216 for required in ("row_id", "deadlock_id", "process_id", "is_victim", "lock_mode", "lock_status", "query_text", "wait_resource", "database"):
217 if required not in field_to_idx:
218 raise SystemExit("missing expected column: {}".format(required))
219
220 data = doc.get("data") or []
221 if not data:
222 raise SystemExit("deadlock-info returned no rows")
223
224 def get_value(row, field):
225 idx = field_to_idx[field]
226 return row[idx] if idx < len(row) else None
227
228 def norm(val):
229 return "" if val is None else str(val).strip()
230
231 has_waiting = any(str(get_value(row, "lock_status")).upper() == "WAITING" for row in data)
232 if not has_waiting:
233 raise SystemExit("no WAITING lock_status found in deadlock-info output")
234
235 table_pattern = re.compile(r"deadlock_(a|b)", re.IGNORECASE)
236 has_expected_query = any(table_pattern.search(str(get_value(row, "query_text"))) for row in data)
237 if not has_expected_query:
238 raise SystemExit("query_text does not reference deadlock tables")
239
240 waiting_rows = [row for row in data if norm(get_value(row, "lock_status")).upper() == "WAITING"]
241 if any(norm(get_value(row, "wait_resource")) == "" for row in waiting_rows):
242 raise SystemExit("WAITING rows must include wait_resource")
243
244 lock_mode_re = re.compile(r"^[A-Za-z0-9_-]+$")
245 if any(norm(get_value(row, "lock_mode")) != "" and not lock_mode_re.match(norm(get_value(row, "lock_mode"))) for row in waiting_rows):
246 raise SystemExit("WAITING rows must include a valid lock_mode")
247
248 victim_counts = {}
249 expected_db = "netdata"
250 has_database = False
251 for row in data:
252 deadlock_id = norm(get_value(row, "deadlock_id"))
253 if deadlock_id == "":
254 raise SystemExit("deadlock_id missing from deadlock-info output")
255 process_id = norm(get_value(row, "process_id"))
256 if process_id == "":
257 raise SystemExit("process_id missing from deadlock-info output")
258 row_id = norm(get_value(row, "row_id"))
259 if row_id != "{}:{}".format(deadlock_id, process_id):
260 raise SystemExit("row_id {} does not match deadlock_id/process_id".format(row_id))
261 victim_counts.setdefault(deadlock_id, 0)
262 if norm(get_value(row, "is_victim")).lower() == "true":
263 victim_counts[deadlock_id] += 1
264 db_val = norm(get_value(row, "database")).lower()
265 if db_val:
266 has_database = True
267 if db_val != expected_db:
268 raise SystemExit("unexpected database value {!r}, expected {!r}".format(db_val, expected_db))
269
270 for deadlock_id, count in victim_counts.items():
271 if count != 1:
272 raise SystemExit("deadlock_id {} has victim count {}, expected 1".format(deadlock_id, count))
273 if not has_database:
274 raise SystemExit("expected at least one row with database populated")
275 PY
276 else
277 python - "$input" <<'PY'
278 import io
279 import json
280 import re
281 import sys
282
283 path = sys.argv[1]
284 with io.open(path, "r", encoding="utf-8") as fh:
285 doc = json.load(fh)
286
287 try:
288 status = int(doc.get("status"))
289 except (TypeError, ValueError):
290 raise SystemExit("unexpected status value: %r" % (doc.get("status"),))
291
292 if status != 200:
293 raise SystemExit("expected status 200, got %s" % status)
294
295 if doc.get("errorMessage"):
296 raise SystemExit("unexpected errorMessage on status 200: %r" % (doc.get("errorMessage"),))
297
298 columns = doc.get("columns") or {}
299 field_to_idx = {}
300 if isinstance(columns, dict):
301 for field, col in columns.items():
302 if not isinstance(col, dict):
303 continue
304 try:
305 field_to_idx[field] = int(col.get("index"))
306 except (TypeError, ValueError):
307 continue
308 else:
309 for idx, col in enumerate(columns):
310 if not isinstance(col, dict):
311 continue
312 field = col.get("field")
313 if field:
314 field_to_idx[field] = idx
315
316 for required in ("row_id", "deadlock_id", "process_id", "is_victim", "lock_mode", "lock_status", "query_text", "wait_resource", "database"):
317 if required not in field_to_idx:
318 raise SystemExit("missing expected column: {}".format(required))
319
320 data = doc.get("data") or []
321 if not data:
322 raise SystemExit("deadlock-info returned no rows")
323
324 def get_value(row, field):
325 idx = field_to_idx[field]
326 return row[idx] if idx < len(row) else None
327
328 def norm(val):
329 return "" if val is None else str(val).strip()
330
331 has_waiting = any(str(get_value(row, "lock_status")).upper() == "WAITING" for row in data)
332 if not has_waiting:
333 raise SystemExit("no WAITING lock_status found in deadlock-info output")
334
335 table_pattern = re.compile(r"deadlock_(a|b)", re.IGNORECASE)
336 has_expected_query = any(table_pattern.search(str(get_value(row, "query_text"))) for row in data)
337 if not has_expected_query:
338 raise SystemExit("query_text does not reference deadlock tables")
339
340 waiting_rows = [row for row in data if norm(get_value(row, "lock_status")).upper() == "WAITING"]
341 if any(norm(get_value(row, "lock_mode")) == "" for row in waiting_rows):
342 raise SystemExit("WAITING rows must include lock_mode")
343 if any(norm(get_value(row, "wait_resource")) == "" for row in waiting_rows):
344 raise SystemExit("WAITING rows must include wait_resource")
345
346 lock_mode_re = re.compile(r"^[A-Za-z0-9_-]+$")
347 if any(not lock_mode_re.match(norm(get_value(row, "lock_mode"))) for row in waiting_rows):
348 raise SystemExit("WAITING rows must include a valid lock_mode")
349
350 victim_counts = {}
351 expected_db = "netdata"
352 has_database = False
353 for row in data:
354 deadlock_id = norm(get_value(row, "deadlock_id"))
355 if deadlock_id == "":
356 raise SystemExit("deadlock_id missing from deadlock-info output")
357 process_id = norm(get_value(row, "process_id"))
358 if process_id == "":
359 raise SystemExit("process_id missing from deadlock-info output")
360 row_id = norm(get_value(row, "row_id"))
361 if row_id != "%s:%s" % (deadlock_id, process_id):
362 raise SystemExit("row_id %s does not match deadlock_id/process_id" % row_id)
363 victim_counts.setdefault(deadlock_id, 0)
364 if norm(get_value(row, "is_victim")).lower() == "true":
365 victim_counts[deadlock_id] += 1
366 db_val = norm(get_value(row, "database")).lower()
367 if db_val:
368 has_database = True
369 if db_val != expected_db:
370 raise SystemExit("unexpected database value %r, expected %r" % (db_val, expected_db))
371
372 for deadlock_id, count in victim_counts.items():
373 if count != 1:
374 raise SystemExit("deadlock_id {} has victim count {}, expected 1".format(deadlock_id, count))
375 if not has_database:
376 raise SystemExit("expected at least one row with database populated")
377 PY
378 fi
379 }
380
381 assert_deadlock_info_empty_success() {
382 local input="$1"
383
384 if command -v python3 >/dev/null 2>&1; then
385 python3 - "$input" <<'PY'
386 import io
387 import json
388 import sys
389
390 path = sys.argv[1]
391 with io.open(path, "r", encoding="utf-8") as fh:
392 doc = json.load(fh)
393
394 try:
395 status = int(doc.get("status"))
396 except (TypeError, ValueError):
397 raise SystemExit("unexpected status value: {!r}".format(doc.get("status")))
398
399 if status != 200:
400 raise SystemExit("expected status 200, got {}".format(status))
401
402 if doc.get("errorMessage"):
403 raise SystemExit("unexpected errorMessage on status 200: {!r}".format(doc.get("errorMessage")))
404
405 data = doc.get("data") or []
406 if len(data) != 0:
407 raise SystemExit("expected no rows, got {}".format(len(data)))
408 PY
409 return
410 fi
411
412 python - "$input" <<'PY'
413 import io
414 import json
415 import sys
416
417 path = sys.argv[1]
418 with open(path, "r") as fh:
419 doc = json.load(fh)
420
421 try:
422 status = int(doc.get("status"))
423 except (TypeError, ValueError):
424 raise SystemExit("unexpected status value: %r" % (doc.get("status"),))
425
426 if status != 200:
427 raise SystemExit("expected status 200, got %s" % status)
428
429 if doc.get("errorMessage"):
430 raise SystemExit("unexpected errorMessage on status 200: %r" % (doc.get("errorMessage"),))
431
432 data = doc.get("data") or []
433 if len(data) != 0:
434 raise SystemExit("expected no rows, got %s" % len(data))
435 PY
436 }
437
438 assert_error_info_not_enabled() {
439 local input="$1"
440
441 if command -v python3 >/dev/null 2>&1; then
442 python3 - "$input" <<'PY'
443 import io
444 import json
445 import sys
446
447 path = sys.argv[1]
448 with io.open(path, "r", encoding="utf-8") as fh:
449 doc = json.load(fh)
450
451 try:
452 status = int(doc.get("status"))
453 except (TypeError, ValueError):
454 raise SystemExit("unexpected status value: {!r}".format(doc.get("status")))
455
456 if status < 400:
457 raise SystemExit("expected error status, got {}".format(status))
458
459 err = str(doc.get("errorMessage") or "").lower()
460 if "not enabled" not in err:
461 raise SystemExit("expected errorMessage to contain 'not enabled', got {!r}".format(err))
462 PY
463 return
464 fi
465
466 python - "$input" <<'PY'
467 import io
468 import json
469 import sys
470
471 path = sys.argv[1]
472 with open(path, "r") as fh:
473 doc = json.load(fh)
474
475 try:
476 status = int(doc.get("status"))
477 except (TypeError, ValueError):
478 raise SystemExit("unexpected status value: %r" % (doc.get("status"),))
479
480 if status < 400:
481 raise SystemExit("expected error status, got %s" % status)
482
483 err = str(doc.get("errorMessage") or "").lower()
484 if "not enabled" not in err:
485 raise SystemExit("expected errorMessage to contain 'not enabled', got %r" % err)
486 PY
487 }
488
489 assert_error_info_has_errors() {
490 local input="$1"
491
492 if command -v python3 >/dev/null 2>&1; then
493 python3 - "$input" <<'PY'
494 import io
495 import json
496 import sys
497
498 path = sys.argv[1]
499 with io.open(path, "r", encoding="utf-8") as fh:
500 doc = json.load(fh)
501
502 try:
503 status = int(doc.get("status"))
504 except (TypeError, ValueError):
505 raise SystemExit("unexpected status value: {!r}".format(doc.get("status")))
506
507 if status != 200:
508 raise SystemExit("expected status 200, got {}".format(status))
509
510 if doc.get("errorMessage"):
511 raise SystemExit("unexpected errorMessage on status 200: {!r}".format(doc.get("errorMessage")))
512
513 columns = doc.get("columns") or {}
514 field_to_idx = {}
515 if isinstance(columns, dict):
516 for field, col in columns.items():
517 if not isinstance(col, dict):
518 continue
519 try:
520 field_to_idx[field] = int(col.get("index"))
521 except (TypeError, ValueError):
522 continue
523 else:
524 for idx, col in enumerate(columns):
525 if not isinstance(col, dict):
526 continue
527 field = col.get("field")
528 if field:
529 field_to_idx[field] = idx
530
531 for required in ("errorNumber", "errorMessage"):
532 if required not in field_to_idx:
533 raise SystemExit("missing expected column: {}".format(required))
534
535 data = doc.get("data") or []
536 if not data:
537 raise SystemExit("error-info returned no rows")
538
539 err_idx = field_to_idx["errorMessage"]
540 num_idx = field_to_idx["errorNumber"]
541 def normalize(val):
542 return "" if val is None else str(val)
543
544 # Error categories to verify:
545 # 1146 - Table doesn't exist (missing_table)
546 # 1062 - Duplicate key (constraint violation)
547 # 1064 - Syntax error (should be captured with synthetic digest)
548 error_categories = {
549 "table_not_found": {"patterns": ["missing_table", "doesn't exist", "does not exist"], "error_nums": [1146], "found": False},
550 "duplicate_key": {"patterns": ["duplicate", "primary", "unique"], "error_nums": [1062], "found": False},
551 "syntax_error": {"patterns": ["syntax", "error in your sql"], "error_nums": [1064], "found": False},
552 }
553
554 for row in data:
555 if num_idx >= len(row) or row[num_idx] is None:
556 continue
557 err_num = row[num_idx]
558 msg = normalize(row[err_idx]).lower()
559 for cat, info in error_categories.items():
560 if info["found"]:
561 continue
562 # Check by error number first
563 if err_num in info.get("error_nums", []):
564 info["found"] = True
565 continue
566 # Fallback to pattern matching
567 for pattern in info["patterns"]:
568 if pattern in msg:
569 info["found"] = True
570 break
571
572 missing = [cat for cat, info in error_categories.items() if not info["found"]]
573 if missing:
574 raise SystemExit("error-info missing error categories: {}".format(", ".join(missing)))
575 PY
576 return
577 fi
578
579 python - "$input" <<'PY'
580 import io
581 import json
582 import sys
583
584 path = sys.argv[1]
585 with open(path, "r") as fh:
586 doc = json.load(fh)
587
588 try:
589 status = int(doc.get("status"))
590 except (TypeError, ValueError):
591 raise SystemExit("unexpected status value: %r" % (doc.get("status"),))
592
593 if status != 200:
594 raise SystemExit("expected status 200, got %s" % status)
595
596 if doc.get("errorMessage"):
597 raise SystemExit("unexpected errorMessage on status 200: %r" % (doc.get("errorMessage"),))
598
599 columns = doc.get("columns") or {}
600 field_to_idx = {}
601 if isinstance(columns, dict):
602 for field, col in columns.items():
603 if not isinstance(col, dict):
604 continue
605 try:
606 field_to_idx[field] = int(col.get("index"))
607 except (TypeError, ValueError):
608 continue
609 else:
610 for idx, col in enumerate(columns):
611 if not isinstance(col, dict):
612 continue
613 field = col.get("field")
614 if field:
615 field_to_idx[field] = idx
616
617 for required in ("errorNumber", "errorMessage"):
618 if required not in field_to_idx:
619 raise SystemExit("missing expected column: %s" % required)
620
621 data = doc.get("data") or []
622 if not data:
623 raise SystemExit("error-info returned no rows")
624
625 err_idx = field_to_idx["errorMessage"]
626 num_idx = field_to_idx["errorNumber"]
627 def normalize(val):
628 return "" if val is None else str(val)
629
630 # Error categories to verify:
631 # 1146 - Table doesn't exist (missing_table)
632 # 1062 - Duplicate key (constraint violation)
633 # 1064 - Syntax error (should be captured with synthetic digest)
634 error_categories = {
635 "table_not_found": {"patterns": ["missing_table", "doesn't exist", "does not exist"], "error_nums": [1146], "found": False},
636 "duplicate_key": {"patterns": ["duplicate", "primary", "unique"], "error_nums": [1062], "found": False},
637 "syntax_error": {"patterns": ["syntax", "error in your sql"], "error_nums": [1064], "found": False},
638 }
639
640 for row in data:
641 if num_idx >= len(row) or row[num_idx] is None:
642 continue
643 err_num = row[num_idx]
644 msg = normalize(row[err_idx]).lower()
645 for cat, info in error_categories.items():
646 if info["found"]:
647 continue
648 if err_num in info.get("error_nums", []):
649 info["found"] = True
650 continue
651 for pattern in info["patterns"]:
652 if pattern in msg:
653 info["found"] = True
654 break
655
656 missing = [cat for cat, info in error_categories.items() if not info["found"]]
657 if missing:
658 raise SystemExit("error-info missing error categories: %s" % ", ".join(missing))
659 PY
660 }
661
662 assert_top_queries_error_attribution_enabled() {
663 local input="$1"
664
665 if command -v python3 >/dev/null 2>&1; then
666 python3 - "$input" <<'PY'
667 import io
668 import json
669 import sys
670
671 path = sys.argv[1]
672 with io.open(path, "r", encoding="utf-8") as fh:
673 doc = json.load(fh)
674
675 columns = doc.get("columns") or {}
676 field_to_idx = {}
677 if isinstance(columns, dict):
678 for field, col in columns.items():
679 if not isinstance(col, dict):
680 continue
681 try:
682 field_to_idx[field] = int(col.get("index"))
683 except (TypeError, ValueError):
684 continue
685 else:
686 for idx, col in enumerate(columns):
687 if not isinstance(col, dict):
688 continue
689 field = col.get("field")
690 if field:
691 field_to_idx[field] = idx
692
693 for required in ("errorAttribution", "errorNumber", "errorMessage"):
694 if required not in field_to_idx:
695 raise SystemExit("missing expected column: {}".format(required))
696
697 data = doc.get("data") or []
698 status_idx = field_to_idx["errorAttribution"]
699 num_idx = field_to_idx["errorNumber"]
700 msg_idx = field_to_idx["errorMessage"]
701
702 matched = False
703 for row in data:
704 if status_idx >= len(row):
705 continue
706 if str(row[status_idx]) != "enabled":
707 continue
708 num = row[num_idx] if num_idx < len(row) else None
709 msg = str(row[msg_idx]).lower() if msg_idx < len(row) else ""
710 if num is not None and "missing_table" in msg:
711 matched = True
712 break
713
714 if not matched:
715 raise SystemExit("no top-queries row had enabled error attribution for missing_table")
716 PY
717 return
718 fi
719
720 python - "$input" <<'PY'
721 import io
722 import json
723 import sys
724
725 path = sys.argv[1]
726 with open(path, "r") as fh:
727 doc = json.load(fh)
728
729 columns = doc.get("columns") or {}
730 field_to_idx = {}
731 if isinstance(columns, dict):
732 for field, col in columns.items():
733 if not isinstance(col, dict):
734 continue
735 try:
736 field_to_idx[field] = int(col.get("index"))
737 except (TypeError, ValueError):
738 continue
739 else:
740 for idx, col in enumerate(columns):
741 if not isinstance(col, dict):
742 continue
743 field = col.get("field")
744 if field:
745 field_to_idx[field] = idx
746
747 for required in ("errorAttribution", "errorNumber", "errorMessage"):
748 if required not in field_to_idx:
749 raise SystemExit("missing expected column: %s" % required)
750
751 data = doc.get("data") or []
752 status_idx = field_to_idx["errorAttribution"]
753 num_idx = field_to_idx["errorNumber"]
754 msg_idx = field_to_idx["errorMessage"]
755
756 matched = False
757 for row in data:
758 if status_idx >= len(row):
759 continue
760 if str(row[status_idx]) != "enabled":
761 continue
762 num = row[num_idx] if num_idx < len(row) else None
763 msg = str(row[msg_idx]).lower() if msg_idx < len(row) else ""
764 if num is not None and "missing_table" in msg:
765 matched = True
766 break
767
768 if not matched:
769 raise SystemExit("no top-queries row had enabled error attribution for missing_table")
770 PY
771 }
772
773 assert_top_queries_error_attribution_not_enabled() {
774 local input="$1"
775
776 if command -v python3 >/dev/null 2>&1; then
777 python3 - "$input" <<'PY'
778 import io
779 import json
780 import sys
781
782 path = sys.argv[1]
783 with io.open(path, "r", encoding="utf-8") as fh:
784 doc = json.load(fh)
785
786 columns = doc.get("columns") or {}
787 field_to_idx = {}
788 if isinstance(columns, dict):
789 for field, col in columns.items():
790 if not isinstance(col, dict):
791 continue
792 try:
793 field_to_idx[field] = int(col.get("index"))
794 except (TypeError, ValueError):
795 continue
796 else:
797 for idx, col in enumerate(columns):
798 if not isinstance(col, dict):
799 continue
800 field = col.get("field")
801 if field:
802 field_to_idx[field] = idx
803
804 if "errorAttribution" not in field_to_idx:
805 raise SystemExit("missing expected column: errorAttribution")
806
807 data = doc.get("data") or []
808 idx = field_to_idx["errorAttribution"]
809 for row in data:
810 if idx >= len(row):
811 continue
812 if str(row[idx]) != "not_enabled":
813 raise SystemExit("expected errorAttribution 'not_enabled', got {!r}".format(row[idx]))
814 PY
815 return
816 fi
817
818 python - "$input" <<'PY'
819 import io
820 import json
821 import sys
822
823 path = sys.argv[1]
824 with open(path, "r") as fh:
825 doc = json.load(fh)
826
827 columns = doc.get("columns") or {}
828 field_to_idx = {}
829 if isinstance(columns, dict):
830 for field, col in columns.items():
831 if not isinstance(col, dict):
832 continue
833 try:
834 field_to_idx[field] = int(col.get("index"))
835 except (TypeError, ValueError):
836 continue
837 else:
838 for idx, col in enumerate(columns):
839 if not isinstance(col, dict):
840 continue
841 field = col.get("field")
842 if field:
843 field_to_idx[field] = idx
844
845 if "errorAttribution" not in field_to_idx:
846 raise SystemExit("missing expected column: errorAttribution")
847
848 data = doc.get("data") or []
849 idx = field_to_idx["errorAttribution"]
850 for row in data:
851 if idx >= len(row):
852 continue
853 if str(row[idx]) != "not_enabled":
854 raise SystemExit("expected errorAttribution 'not_enabled', got %r" % row[idx])
855 PY
856 }
857
858 capture_statement_history_states() {
859 local output
860 output="$(mysql_query_root "
861 SELECT
862 COALESCE(MAX(CASE WHEN NAME = 'events_statements_history_long' THEN ENABLED END), 'NO') AS history_long,
863 COALESCE(MAX(CASE WHEN NAME = 'events_statements_history' THEN ENABLED END), 'NO') AS history,
864 COALESCE(MAX(CASE WHEN NAME = 'events_statements_current' THEN ENABLED END), 'NO') AS history_current
865 FROM performance_schema.setup_consumers
866 WHERE NAME IN ('events_statements_history_long','events_statements_history','events_statements_current');")"
867 local history_long
868 local history
869 local history_current
870 IFS=$'\t' read -r history_long history history_current <<<"$output"
871 MYSQL_HISTORY_LONG_STATE="$history_long"
872 MYSQL_HISTORY_STATE="$history"
873 MYSQL_HISTORY_CURRENT_STATE="$history_current"
874 }
875
876 disable_statement_history_consumers() {
877 mysql_exec_root "UPDATE performance_schema.setup_consumers SET ENABLED = 'NO' WHERE NAME IN ('events_statements_history_long','events_statements_history','events_statements_current');"
878 }
879
880 enable_statement_history_consumers() {
881 mysql_exec_root "UPDATE performance_schema.setup_consumers SET ENABLED = 'YES' WHERE NAME IN ('events_statements_history_long','events_statements_history','events_statements_current');"
882 }
883
884 restore_statement_history_consumers() {
885 if [ -n "${MYSQL_HISTORY_LONG_STATE:-}" ]; then
886 mysql_exec_root "UPDATE performance_schema.setup_consumers SET ENABLED = '${MYSQL_HISTORY_LONG_STATE}' WHERE NAME = 'events_statements_history_long';"
887 fi
888 if [ -n "${MYSQL_HISTORY_STATE:-}" ]; then
889 mysql_exec_root "UPDATE performance_schema.setup_consumers SET ENABLED = '${MYSQL_HISTORY_STATE}' WHERE NAME = 'events_statements_history';"
890 fi
891 if [ -n "${MYSQL_HISTORY_CURRENT_STATE:-}" ]; then
892 mysql_exec_root "UPDATE performance_schema.setup_consumers SET ENABLED = '${MYSQL_HISTORY_CURRENT_STATE}' WHERE NAME = 'events_statements_current';"
893 fi
894 }
895
896 verify_deadlock_info_no_deadlock() {
897 local output
898
899 output="$(run_function mysql deadlock-info '__job:local' 'false')"
900 validate "$output"
901 assert_deadlock_info_empty_success "$output"
902 }
903
904 verify_deadlock_info() {
905 local output=""
906 local found="false"
907
908 for attempt in 1 2 3 4 5; do
909 induce_deadlock_once
910 output="$(run_function mysql deadlock-info '__job:local' 'false')"
911 if has_min_rows "$output" 1; then
912 validate "$output" --min-rows 1
913 if assert_deadlock_info_content "$output"; then
914 found="true"
915 break
916 fi
917 fi
918 sleep 1
919 done
920
921 if [ "$found" != "true" ]; then
922 echo "deadlock-info did not produce valid deadlock attribution after 5 attempts" >&2
923 return 1
924 fi
925
926 # Verify column visibility rules
927 assert_column_visibility "$output" "deadlock-info"
928 }
929
930 verify_deadlock_info_no_deadlock
931 verify_deadlock_info
932
933 capture_statement_history_states
934 disable_statement_history_consumers
935
936 error_output="$(run_function mysql error-info '__job:local' 'false')"
937 assert_error_info_not_enabled "$error_output"
938
939 run_top_queries mysql
940 assert_top_queries_error_attribution_not_enabled "$WORKDIR/mysql-top-queries.json"
941
942 enable_statement_history_consumers
943
944 # Generate errors for multiple categories:
945 # 1. Table not found (error 1146)
946 for _ in 1 2 3; do
947 mysql_exec_root_allow_error "SELECT * FROM missing_table;"
948 done
949
950 # 2. Duplicate key / constraint violation (error 1062)
951 for _ in 1 2 3; do
952 mysql_exec_root_allow_error "INSERT INTO error_test (id, unique_col, int_col) VALUES (1, 'new_value', 200);"
953 mysql_exec_root_allow_error "INSERT INTO error_test (id, unique_col, int_col) VALUES (99, 'existing_value', 300);"
954 done
955
956 # 3. Syntax errors (error 1064)
957 # These have NULL DIGEST in performance_schema because they fail during parsing
958 # before instrumentation. The collector should generate a synthetic digest.
959 for _ in 1 2 3; do
960 mysql_exec_root_allow_error "SELECT * FROM WHERE 1=1;"
961 mysql_exec_root_allow_error "SELEC * FROM error_test;"
962 done
963
964 sleep 1
965
966 error_output="$(run_function mysql error-info '__job:local' 'true')"
967 assert_error_info_has_errors "$error_output"
968 assert_column_visibility "$error_output" "error-info"
969 assert_unique_key_populated "$error_output" "error-info"
970
971 run_top_queries mysql
972 assert_top_queries_error_attribution_enabled "$WORKDIR/mysql-top-queries.json"
973 assert_column_visibility "$WORKDIR/mysql-top-queries.json" "top-queries"
974
975 restore_statement_history_consumers
976
977 echo "E2E checks passed for ${MYSQL_VARIANT_LABEL}." >&2