matrixone-python-sdk 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
matrixone/cli_tools.py ADDED
@@ -0,0 +1,1895 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Copyright 2021 - 2022 Matrix Origin
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ """
18
+ MatrixOne Interactive Diagnostic Tool
19
+
20
+ A command-line tool for diagnosing and inspecting MatrixOne database objects,
21
+ especially secondary indexes and vector indexes.
22
+ """
23
+
24
+ import cmd
25
+ import logging
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from .client import Client
31
+
32
+ # Set default logging level to ERROR to keep output clean
33
+ # Users can override this with --log-level parameter
34
+ logging.getLogger('matrixone').setLevel(logging.ERROR)
35
+
36
+ # Try to import prompt_toolkit for better input experience
37
+ try:
38
+ from prompt_toolkit import PromptSession
39
+ from prompt_toolkit.completion import Completer, Completion
40
+ from prompt_toolkit.formatted_text import HTML
41
+ from prompt_toolkit.history import FileHistory
42
+ from prompt_toolkit.styles import Style
43
+
44
+ PROMPT_TOOLKIT_AVAILABLE = True
45
+ except ImportError:
46
+ PROMPT_TOOLKIT_AVAILABLE = False
47
+
48
+
49
+ # Custom completer for mo-diag commands
50
+ if PROMPT_TOOLKIT_AVAILABLE:
51
+
52
+ class MODiagCompleter(Completer):
53
+ """Smart completer for mo-diag commands that provides table and database name completion"""
54
+
55
+ def __init__(self, cli_instance):
56
+ self.cli = cli_instance
57
+
58
+ def get_completions(self, document, complete_event):
59
+ """Generate completions based on current input"""
60
+ text = document.text_before_cursor
61
+ words = text.split()
62
+
63
+ # Available commands
64
+ all_commands = [
65
+ 'show_indexes',
66
+ 'show_all_indexes',
67
+ 'verify_counts',
68
+ 'show_ivf_status',
69
+ 'show_table_stats',
70
+ 'flush_table',
71
+ 'tables',
72
+ 'databases',
73
+ 'sql',
74
+ 'use',
75
+ 'connect',
76
+ 'history',
77
+ 'help',
78
+ 'exit',
79
+ ]
80
+
81
+ # If empty or only whitespace, suggest commands
82
+ if not words:
83
+ for command in all_commands:
84
+ yield Completion(command, start_position=0)
85
+ return
86
+
87
+ # If we're typing the first word (command), complete it
88
+ if len(words) == 1 and not text.endswith(' '):
89
+ partial_command = words[0]
90
+ for command in all_commands:
91
+ if command.startswith(partial_command):
92
+ yield Completion(command, start_position=-len(partial_command))
93
+ return
94
+
95
+ command = words[0]
96
+
97
+ # Commands that expect table name as first argument
98
+ table_commands = ['show_indexes', 'verify_counts', 'show_table_stats', 'flush_table']
99
+ # Commands that expect database name
100
+ database_commands = ['use']
101
+
102
+ # Determine what to complete
103
+ if command in table_commands:
104
+ # If we're on the first argument after the command
105
+ if len(words) == 1 or (len(words) == 2 and not text.endswith(' ')):
106
+ # Complete table names
107
+ partial = words[1] if len(words) == 2 else ''
108
+ for table in self._get_tables():
109
+ if table.startswith(partial):
110
+ yield Completion(table, start_position=-len(partial))
111
+ # Second argument might be database name
112
+ elif len(words) == 2 or (len(words) == 3 and not text.endswith(' ')):
113
+ partial = words[2] if len(words) == 3 else ''
114
+ for db in self._get_databases():
115
+ if db.startswith(partial):
116
+ yield Completion(db, start_position=-len(partial))
117
+
118
+ elif command in database_commands:
119
+ # Complete database names
120
+ if len(words) == 1 or (len(words) == 2 and not text.endswith(' ')):
121
+ partial = words[1] if len(words) == 2 else ''
122
+ for db in self._get_databases():
123
+ if db.startswith(partial):
124
+ yield Completion(db, start_position=-len(partial))
125
+
126
+ elif command == 'show_ivf_status':
127
+ # Can take -t <table> or database name
128
+ if '-t' in words:
129
+ # After -t, complete table names
130
+ t_index = words.index('-t')
131
+ if len(words) == t_index + 1 or (len(words) == t_index + 2 and not text.endswith(' ')):
132
+ partial = words[t_index + 1] if len(words) == t_index + 2 else ''
133
+ for table in self._get_tables():
134
+ if table.startswith(partial):
135
+ yield Completion(table, start_position=-len(partial))
136
+ else:
137
+ # First argument is database name
138
+ if len(words) == 1 or (len(words) == 2 and not text.endswith(' ')):
139
+ partial = words[1] if len(words) == 2 else ''
140
+ for db in self._get_databases():
141
+ if db.startswith(partial):
142
+ yield Completion(db, start_position=-len(partial))
143
+
144
+ def _get_tables(self):
145
+ """Get list of tables in current database"""
146
+ if not self.cli.client or not self.cli.current_database:
147
+ return []
148
+ try:
149
+ result = self.cli.client.execute("SHOW TABLES")
150
+ return [row[0] for row in result.fetchall()]
151
+ except Exception:
152
+ return []
153
+
154
+ def _get_databases(self):
155
+ """Get list of all databases"""
156
+ if not self.cli.client:
157
+ return []
158
+ try:
159
+ result = self.cli.client.execute("SHOW DATABASES")
160
+ return [row[0] for row in result.fetchall()]
161
+ except Exception:
162
+ return []
163
+
164
+
165
+ # ANSI Color codes for terminal output
166
+ class Colors:
167
+ """ANSI color codes"""
168
+
169
+ RESET = '\033[0m'
170
+ BOLD = '\033[1m'
171
+
172
+ # Foreground colors
173
+ RED = '\033[91m'
174
+ GREEN = '\033[92m'
175
+ YELLOW = '\033[93m'
176
+ BLUE = '\033[94m'
177
+ MAGENTA = '\033[95m'
178
+ CYAN = '\033[96m'
179
+ WHITE = '\033[97m'
180
+
181
+ # Background colors
182
+ BG_RED = '\033[101m'
183
+ BG_GREEN = '\033[102m'
184
+ BG_YELLOW = '\033[103m'
185
+
186
+ @staticmethod
187
+ def disable():
188
+ """Disable colors (for non-terminal output)"""
189
+ Colors.RESET = ''
190
+ Colors.BOLD = ''
191
+ Colors.RED = ''
192
+ Colors.GREEN = ''
193
+ Colors.YELLOW = ''
194
+ Colors.BLUE = ''
195
+ Colors.MAGENTA = ''
196
+ Colors.CYAN = ''
197
+ Colors.WHITE = ''
198
+ Colors.BG_RED = ''
199
+ Colors.BG_GREEN = ''
200
+ Colors.BG_YELLOW = ''
201
+
202
+
203
+ # Check if output is to a terminal
204
+ if not sys.stdout.isatty():
205
+ Colors.disable()
206
+
207
+
208
+ def success(msg):
209
+ """Print success message in green"""
210
+ return f"{Colors.GREEN}{msg}{Colors.RESET}"
211
+
212
+
213
+ def error(msg):
214
+ """Print error message in red"""
215
+ return f"{Colors.RED}{msg}{Colors.RESET}"
216
+
217
+
218
+ def warning(msg):
219
+ """Print warning message in yellow"""
220
+ return f"{Colors.YELLOW}{msg}{Colors.RESET}"
221
+
222
+
223
+ def info(msg):
224
+ """Print info message in cyan"""
225
+ return f"{Colors.CYAN}{msg}{Colors.RESET}"
226
+
227
+
228
+ def bold(msg):
229
+ """Print message in bold"""
230
+ return f"{Colors.BOLD}{msg}{Colors.RESET}"
231
+
232
+
233
+ def header(msg):
234
+ """Print header in bold cyan"""
235
+ return f"{Colors.BOLD}{Colors.CYAN}{msg}{Colors.RESET}"
236
+
237
+
238
+ class MatrixOneCLI(cmd.Cmd):
239
+ """Interactive CLI for MatrixOne diagnostics"""
240
+
241
+ intro = """
242
+ ╔══════════════════════════════════════════════════════════════╗
243
+ ║ MatrixOne Interactive Diagnostic Tool ║
244
+ ║ ║
245
+ ║ Type help or ? to list commands. ║
246
+ ║ Type help <command> for detailed help on a command. ║
247
+ ║ ║
248
+ ║ Tips: ║
249
+ ║ • Press Tab for auto-completion (tables/databases) ║
250
+ ║ • Use ↑/↓ arrows to browse command history ║
251
+ ║ • Press Ctrl+R for history search ║
252
+ ╚══════════════════════════════════════════════════════════════╝
253
+ """
254
+
255
+ prompt = f'{Colors.BOLD}{Colors.GREEN}MO-DIAG>{Colors.RESET} '
256
+
257
+ def __init__(self, client: Optional[Client] = None):
258
+ """
259
+ Initialize the CLI tool.
260
+
261
+ Args:
262
+ client: Optional MatrixOne client. If not provided, you'll need to connect manually.
263
+ """
264
+ super().__init__()
265
+ self.client = client
266
+ self.current_database = None
267
+
268
+ # Setup prompt_toolkit session if available
269
+ if PROMPT_TOOLKIT_AVAILABLE:
270
+ # Setup command history file
271
+ history_file = Path.home() / '.mo_diag_history'
272
+
273
+ # Create completer
274
+ completer = MODiagCompleter(self)
275
+
276
+ self.session = PromptSession(
277
+ history=FileHistory(str(history_file)),
278
+ completer=completer,
279
+ complete_while_typing=False, # Only complete on Tab
280
+ style=Style.from_dict(
281
+ {
282
+ 'prompt': 'bold ansigreen',
283
+ 'database': 'bold ansiyellow',
284
+ }
285
+ ),
286
+ )
287
+ else:
288
+ self.session = None
289
+
290
+ if self.client and hasattr(self.client, '_connection_params'):
291
+ self.current_database = self.client._connection_params.get('database')
292
+ if self.current_database:
293
+ self.prompt = (
294
+ f'{Colors.BOLD}{Colors.GREEN}MO-DIAG{Colors.RESET}{Colors.BOLD}'
295
+ f'[{Colors.YELLOW}{self.current_database}{Colors.RESET}{Colors.BOLD}]'
296
+ f'{Colors.GREEN}>{Colors.RESET} '
297
+ )
298
+
299
+ def cmdloop(self, intro=None):
300
+ """Override cmdloop to use prompt_toolkit for better input experience"""
301
+ if not PROMPT_TOOLKIT_AVAILABLE or not self.session:
302
+ # Fall back to standard cmdloop
303
+ return super().cmdloop(intro)
304
+
305
+ # Print intro
306
+ self.preloop()
307
+ if intro is not None:
308
+ self.intro = intro
309
+ if self.intro:
310
+ self.stdout.write(str(self.intro) + "\n")
311
+
312
+ stop = None
313
+ while not stop:
314
+ try:
315
+ # Create colored prompt
316
+ if self.current_database:
317
+ prompt_text = HTML(
318
+ f'<prompt>MO-DIAG</prompt>[<database>{self.current_database}</database>]<prompt>&gt;</prompt> '
319
+ )
320
+ else:
321
+ prompt_text = HTML('<prompt>MO-DIAG&gt;</prompt> ')
322
+
323
+ line = self.session.prompt(prompt_text)
324
+ line = self.precmd(line)
325
+ stop = self.onecmd(line)
326
+ stop = self.postcmd(stop, line)
327
+ except KeyboardInterrupt:
328
+ print("^C")
329
+ except EOFError:
330
+ print()
331
+ break
332
+ self.postloop()
333
+
334
+ def do_connect(self, arg):
335
+ """
336
+ Connect to MatrixOne database.
337
+
338
+ Usage: connect <host> <port> <user> <password> [database]
339
+ Example: connect localhost 6001 root 111 test
340
+ """
341
+ args = arg.split()
342
+ if len(args) < 4:
343
+ print("❌ Usage: connect <host> <port> <user> <password> [database]")
344
+ return
345
+
346
+ host = args[0]
347
+ port = int(args[1])
348
+ user = args[2]
349
+ password = args[3]
350
+ database = args[4] if len(args) > 4 else None
351
+
352
+ try:
353
+ if not self.client:
354
+ self.client = Client()
355
+
356
+ self.client.connect(host=host, port=port, user=user, password=password, database=database)
357
+ self.current_database = database
358
+ if database:
359
+ self.prompt = f'{Colors.BOLD}{Colors.GREEN}MO-DIAG{Colors.RESET}{Colors.BOLD}[{Colors.YELLOW}{database}{Colors.RESET}{Colors.BOLD}]{Colors.GREEN}>{Colors.RESET} '
360
+ else:
361
+ self.prompt = f'{Colors.BOLD}{Colors.GREEN}MO-DIAG>{Colors.RESET} '
362
+ print(
363
+ f"{Colors.GREEN}✓ Connected to {host}:{port}"
364
+ + (f" (database: {database})" if database else "")
365
+ + Colors.RESET
366
+ )
367
+ except Exception as e:
368
+ print(f"{Colors.RED}❌ Connection failed: {e}{Colors.RESET}")
369
+
370
+ def do_use(self, arg):
371
+ """
372
+ Switch to a different database.
373
+
374
+ Usage: use <database>
375
+ Example: use test
376
+ """
377
+ if not arg:
378
+ print("❌ Usage: use <database>")
379
+ return
380
+
381
+ if not self.client:
382
+ print("❌ Not connected. Use 'connect' first.")
383
+ return
384
+
385
+ # Remove trailing semicolon if present
386
+ database = arg.strip().rstrip(';')
387
+
388
+ try:
389
+ self.client.execute(f"USE {database}")
390
+ self.current_database = database
391
+ self.prompt = f'{Colors.BOLD}{Colors.GREEN}MO-DIAG{Colors.RESET}{Colors.BOLD}[{Colors.YELLOW}{database}{Colors.RESET}{Colors.BOLD}]{Colors.GREEN}>{Colors.RESET} '
392
+ print(f"{Colors.GREEN}✓ Switched to database: {database}{Colors.RESET}")
393
+ except Exception as e:
394
+ print(f"❌ Failed to switch database: {e}")
395
+
396
+ def do_show_indexes(self, arg):
397
+ """
398
+ Show all secondary indexes for a table, including IVF, HNSW, Fulltext, and regular indexes.
399
+
400
+ Uses vertical output format (like MySQL \\G) for easy reading of long table names.
401
+
402
+ Usage: show_indexes <table_name> [database]
403
+
404
+ Example:
405
+ show_indexes cms_all_content_chunk_info
406
+ show_indexes cms_all_content_chunk_info repro3
407
+ """
408
+ # Remove trailing semicolon
409
+ arg = arg.strip().rstrip(';')
410
+ args = arg.split()
411
+ if not args:
412
+ print("❌ Usage: show_indexes <table_name> [database]")
413
+ return
414
+
415
+ if not self.client:
416
+ print("❌ Not connected. Use 'connect' first.")
417
+ return
418
+
419
+ table_name = args[0]
420
+ database = args[1] if len(args) > 1 else self.current_database
421
+
422
+ try:
423
+ # Use the new comprehensive index detail API
424
+ indexes = self.client.get_table_indexes_detail(table_name, database)
425
+
426
+ if not indexes:
427
+ print(f"⚠️ No secondary indexes found for table '{table_name}' in database '{database}'")
428
+ return
429
+
430
+ table_info = f"'{database}.{table_name}'"
431
+ print(f"\n{header('📊 Secondary Indexes for')} {bold(table_info)}\n")
432
+
433
+ # Vertical display format (like MySQL \G)
434
+ for i, idx in enumerate(indexes, 1):
435
+ index_name = idx['index_name']
436
+ algo = idx['algo'] if idx['algo'] else 'regular'
437
+ table_type = idx['algo_table_type'] if idx['algo_table_type'] else '-'
438
+ physical_table = idx['physical_table_name']
439
+
440
+ # Filter out internal columns like __mo_alias___mo_cpkey_col
441
+ user_columns = [col for col in idx['columns'] if not col.startswith('__mo_alias_')]
442
+ columns = ', '.join(user_columns) if user_columns else 'N/A'
443
+
444
+ # Color code by algorithm type
445
+ if algo == 'ivfflat':
446
+ algo_display = f"{Colors.CYAN}{algo}{Colors.RESET}"
447
+ elif algo == 'hnsw':
448
+ algo_display = f"{Colors.GREEN}{algo}{Colors.RESET}"
449
+ elif algo == 'fulltext':
450
+ algo_display = f"{Colors.YELLOW}{algo}{Colors.RESET}"
451
+ else:
452
+ algo_display = algo
453
+
454
+ # Print in vertical format
455
+ print(info(f"{'*' * 27} {i}. row {'*' * 27}"))
456
+ print(f" {bold('Index Name')}: {Colors.CYAN}{index_name}{Colors.RESET}")
457
+ print(f" {bold('Algorithm')}: {algo_display}")
458
+ print(f" {bold('Table Type')}: {table_type}")
459
+ print(f" {bold('Physical Table')}: {physical_table}")
460
+ print(f" {bold('Columns')}: {columns}")
461
+
462
+ # For vector/fulltext indexes, show table statistics using metadata.scan interface
463
+ if algo in ['ivfflat', 'hnsw', 'fulltext']:
464
+ try:
465
+ # Use SDK's metadata.scan interface with columns="*" to get structured results
466
+ stats = self.client.metadata.scan(database, physical_table, columns="*")
467
+
468
+ if stats:
469
+ # Aggregate statistics from all objects
470
+ total_rows = 0
471
+ total_compress_size = 0
472
+ total_origin_size = 0
473
+
474
+ # Deduplicate by object_name (same logic as show_table_stats)
475
+ seen_objects = set()
476
+ object_count = 0
477
+
478
+ for obj in stats:
479
+ # MetadataRow has attributes, not dictionary keys
480
+ object_name = getattr(obj, 'object_name', None)
481
+ if object_name and object_name not in seen_objects:
482
+ seen_objects.add(object_name)
483
+ object_count += 1
484
+
485
+ # Sum up statistics using attributes
486
+ row_cnt = getattr(obj, 'row_cnt', None) or getattr(obj, 'rows_cnt', 0) or 0
487
+ total_rows += int(row_cnt) if row_cnt else 0
488
+
489
+ compress_size = getattr(obj, 'compress_size', 0) or 0
490
+ total_compress_size += int(compress_size) if compress_size else 0
491
+
492
+ origin_size = getattr(obj, 'origin_size', 0) or 0
493
+ total_origin_size += int(origin_size) if origin_size else 0
494
+
495
+ # Format sizes
496
+ def format_size(size_bytes):
497
+ if size_bytes >= 1024 * 1024:
498
+ return f"{size_bytes / (1024 * 1024):.2f} MB"
499
+ elif size_bytes >= 1024:
500
+ return f"{size_bytes / 1024:.2f} KB"
501
+ else:
502
+ return f"{size_bytes} B"
503
+
504
+ compress_size_str = format_size(total_compress_size)
505
+ origin_size_str = format_size(total_origin_size)
506
+
507
+ print(f" {bold('Statistics')}:")
508
+ print(f" - Objects: {object_count}")
509
+ print(f" - Rows: {total_rows:,}")
510
+ print(f" - Compressed Size: {compress_size_str}")
511
+ print(f" - Original Size: {origin_size_str}")
512
+ except Exception:
513
+ # If stats not available, just skip (no error message needed)
514
+ pass
515
+
516
+ # Summary
517
+ print(info("=" * 60))
518
+
519
+ algo_counts = {}
520
+ for idx in indexes:
521
+ algo = idx['algo'] if idx['algo'] else 'regular'
522
+ algo_counts[algo] = algo_counts.get(algo, 0) + 1
523
+
524
+ summary_parts = []
525
+ for algo, count in sorted(algo_counts.items()):
526
+ summary_parts.append(f"{count} {algo}")
527
+
528
+ print(bold(f"Total: {len(indexes)} index tables") + f" ({', '.join(summary_parts)})" + "\n")
529
+
530
+ except Exception as e:
531
+ print(f"❌ Error: {e}")
532
+
533
+ def do_show_all_indexes(self, arg):
534
+ """
535
+ Show index health report for all tables with secondary indexes.
536
+
537
+ This command performs diagnostic checks including:
538
+ - Row count consistency between main table and index tables
539
+ - Vector index building status (IVF/HNSW)
540
+ - Index type distribution
541
+ - Problem detection
542
+
543
+ Usage: show_all_indexes [database]
544
+ Example:
545
+ show_all_indexes
546
+ show_all_indexes repro3
547
+ """
548
+ # Remove trailing semicolon
549
+ arg = arg.strip().rstrip(';')
550
+ database = arg if arg else self.current_database
551
+
552
+ if not database:
553
+ print("❌ No database specified and no current database set. Use 'use <database>' or provide database name.")
554
+ return
555
+
556
+ if not self.client:
557
+ print("❌ Not connected. Use 'connect' first.")
558
+ return
559
+
560
+ try:
561
+ # Get all tables with indexes
562
+ sql = """
563
+ SELECT DISTINCT mo_tables.relname AS table_name
564
+ FROM mo_catalog.mo_indexes
565
+ JOIN mo_catalog.mo_tables ON mo_indexes.table_id = mo_tables.rel_id
566
+ WHERE mo_indexes.type IN ('MULTIPLE', 'UNIQUE') AND mo_tables.reldatabase = ?
567
+ ORDER BY mo_tables.relname
568
+ """
569
+ result = self.client.execute(sql, (database,))
570
+ tables = [row[0] for row in result.fetchall()]
571
+
572
+ if not tables:
573
+ print(f"⚠️ No tables with secondary indexes found in database '{database}'")
574
+ return
575
+
576
+ print(f"\n{header('📊 Index Health Report')} for Database '{database}':")
577
+ print("=" * 120)
578
+
579
+ healthy_tables = []
580
+ attention_tables = []
581
+
582
+ # Check each table
583
+ for table_name in tables:
584
+ try:
585
+ # Get index details
586
+ indexes = self.client.get_table_indexes_detail(table_name, database)
587
+ index_count = len(set(idx['index_name'] for idx in indexes))
588
+
589
+ # Determine if table has special indexes (IVF/HNSW/Fulltext)
590
+ has_vector_or_fulltext = any(idx['algo'] in ['ivfflat', 'hnsw', 'fulltext'] for idx in indexes)
591
+
592
+ # Check row consistency (only for regular/UNIQUE indexes)
593
+ consistency_status = None
594
+ has_issue = False
595
+ issue_detail = None
596
+
597
+ if not has_vector_or_fulltext:
598
+ # Only check row consistency for tables with regular/UNIQUE indexes
599
+ try:
600
+ row_count = self.client.verify_table_index_counts(table_name)
601
+ consistency_status = f"✓ {row_count:,} rows"
602
+ except ValueError as e:
603
+ consistency_status = "❌ Mismatch"
604
+ has_issue = True
605
+ issue_detail = str(e).split('\n')[0] # First line of error
606
+ except Exception:
607
+ consistency_status = "⚠️ Unknown"
608
+ else:
609
+ # For vector/fulltext indexes, just show row count without verification
610
+ try:
611
+ result = self.client.execute(f"SELECT COUNT(*) FROM `{table_name}`")
612
+ row_count = result.fetchone()[0]
613
+ consistency_status = f"{row_count:,} rows"
614
+ except Exception:
615
+ consistency_status = "Unknown"
616
+
617
+ # Check IVF/HNSW/Fulltext status
618
+ special_index_status = None
619
+ for idx in indexes:
620
+ if idx['algo'] == 'ivfflat':
621
+ try:
622
+ stats = self.client.vector_ops.get_ivf_stats(table_name, idx['columns'][0])
623
+ if stats and 'distribution' in stats:
624
+ centroid_ids = stats['distribution'].get('centroid_id', [])
625
+ centroid_counts = stats['distribution'].get('centroid_count', [])
626
+ centroid_count = len(centroid_ids)
627
+ total_vectors = sum(centroid_counts) if centroid_counts else 0
628
+
629
+ if centroid_count > 0 and total_vectors > 0:
630
+ special_index_status = f"IVF: {centroid_count} centroids, {total_vectors} vectors"
631
+ elif centroid_count > 0:
632
+ special_index_status = f"IVF: {centroid_count} centroids"
633
+ else:
634
+ special_index_status = "IVF: building"
635
+ has_issue = True
636
+ issue_detail = "IVF index not built yet"
637
+ else:
638
+ special_index_status = "IVF: no stats available"
639
+ except Exception as e:
640
+ error_msg = str(e)
641
+ # Truncate long error messages for display
642
+ if len(error_msg) > 30:
643
+ error_short = error_msg[:27] + "..."
644
+ else:
645
+ error_short = error_msg
646
+ special_index_status = f"IVF: error ({error_short})"
647
+ has_issue = True
648
+ if not issue_detail:
649
+ issue_detail = f"Failed to get IVF stats: {error_msg}"
650
+ elif idx['algo'] == 'hnsw':
651
+ special_index_status = "HNSW index"
652
+ elif idx['algo'] == 'fulltext':
653
+ special_index_status = "Fulltext index"
654
+
655
+ # Categorize table
656
+ table_info = {
657
+ 'name': table_name,
658
+ 'index_count': index_count,
659
+ 'consistency': consistency_status,
660
+ 'special_index_status': special_index_status,
661
+ 'has_issue': has_issue,
662
+ 'issue_detail': issue_detail,
663
+ }
664
+
665
+ if has_issue:
666
+ attention_tables.append(table_info)
667
+ else:
668
+ healthy_tables.append(table_info)
669
+
670
+ except Exception as e:
671
+ # If we can't check this table, mark it as needing attention
672
+ attention_tables.append(
673
+ {
674
+ 'name': table_name,
675
+ 'index_count': '?',
676
+ 'consistency': '❌ Error',
677
+ 'special_index_status': None,
678
+ 'has_issue': True,
679
+ 'issue_detail': str(e)[:50],
680
+ }
681
+ )
682
+
683
+ # Display healthy tables
684
+ if healthy_tables:
685
+ print(f"\n{success('✓ HEALTHY')} ({len(healthy_tables)} tables)")
686
+ print("-" * 120)
687
+ print(f"{'Table Name':<35} | {'Indexes':<8} | {'Row Count':<20} | {'Notes'}")
688
+ print("-" * 120)
689
+
690
+ for table in healthy_tables:
691
+ notes = table['special_index_status'] if table['special_index_status'] else '-'
692
+ print(f"{table['name']:<35} | {table['index_count']:<8} | {table['consistency']:<20} | {notes}")
693
+
694
+ # Display tables needing attention
695
+ if attention_tables:
696
+ print(f"\n{warning('⚠️ ATTENTION NEEDED')} ({len(attention_tables)} tables)")
697
+ print("-" * 120)
698
+ print(f"{'Table Name':<35} | {'Issue':<40} | {'Details'}")
699
+ print("-" * 120)
700
+
701
+ for table in attention_tables:
702
+ if 'Mismatch' in (table['consistency'] or ''):
703
+ issue = "Row count mismatch between indexes"
704
+ details = table['issue_detail'] if table['issue_detail'] else "Check with verify_counts"
705
+ elif 'building' in (table.get('special_index_status') or ''):
706
+ issue = "Vector index building incomplete"
707
+ details = table['issue_detail'] if table['issue_detail'] else "Check with show_ivf_status"
708
+ elif 'error' in (table.get('special_index_status') or '').lower():
709
+ issue = "Vector index error"
710
+ details = table['issue_detail'] if table['issue_detail'] else "Check index status"
711
+ elif 'Error' in (table['consistency'] or ''):
712
+ issue = "Unable to verify table"
713
+ details = table['issue_detail'] if table['issue_detail'] else "Unknown error"
714
+ else:
715
+ issue = "Unknown issue"
716
+ details = str(table.get('issue_detail', '-'))
717
+
718
+ print(f"{table['name']:<35} | {issue:<40} | {details[:38]}")
719
+
720
+ # Summary
721
+ print("\n" + "=" * 120)
722
+ print(f"{bold('Summary:')}")
723
+ print(f" {success('✓')} {len(healthy_tables)} healthy tables")
724
+ if attention_tables:
725
+ print(f" {warning('⚠️ ')} {len(attention_tables)} tables need attention")
726
+ else:
727
+ print(f" {success('✓')} All indexes healthy!")
728
+ print(f" Total: {len(tables)} tables with indexes\n")
729
+
730
+ if attention_tables:
731
+ print(f"{info('💡 Tip:')} Use 'verify_counts <table>' or 'show_ivf_status' for detailed diagnostics")
732
+
733
+ except Exception as e:
734
+ print(f"❌ Error: {e}")
735
+
736
+ def do_verify_counts(self, arg):
737
+ """
738
+ Verify row counts between main table and all its index tables.
739
+
740
+ Usage: verify_counts <table_name> [database]
741
+ Example:
742
+ verify_counts cms_all_content_chunk_info
743
+ verify_counts cms_all_content_chunk_info repro3
744
+ """
745
+ # Remove trailing semicolon
746
+ arg = arg.strip().rstrip(';')
747
+ args = arg.split()
748
+ if not args:
749
+ print("❌ Usage: verify_counts <table_name> [database]")
750
+ return
751
+
752
+ if not self.client:
753
+ print("❌ Not connected. Use 'connect' first.")
754
+ return
755
+
756
+ table_name = args[0]
757
+ database = args[1] if len(args) > 1 else self.current_database
758
+
759
+ if not database:
760
+ print("❌ No database specified and no current database set.")
761
+ return
762
+
763
+ try:
764
+ # Switch to the database temporarily if needed
765
+ current_db = self.client._connection_params.get('database')
766
+ if current_db != database:
767
+ self.client.execute(f"USE {database}")
768
+
769
+ # Get index tables
770
+ index_tables = self.client.get_secondary_index_tables(table_name, database)
771
+
772
+ if not index_tables:
773
+ print(f"⚠️ No secondary indexes found for table '{table_name}'")
774
+ # Still show main table count
775
+ result = self.client.execute(f"SELECT COUNT(*) FROM `{table_name}`")
776
+ count = result.fetchone()[0]
777
+ print(f"Main table '{table_name}': {count:,} rows")
778
+ return
779
+
780
+ # Build verification SQL
781
+ from .index_utils import build_verify_counts_sql
782
+
783
+ sql = build_verify_counts_sql(table_name, index_tables)
784
+
785
+ result = self.client.execute(sql)
786
+ row = result.fetchone()
787
+
788
+ main_count = row[0]
789
+ mismatches = []
790
+
791
+ table_info = f"'{database}.{table_name}'"
792
+ print(f"\n{header('📊 Row Count Verification for')} {bold(table_info)}")
793
+ print(info("=" * 80))
794
+ print(bold(f"Main table: {main_count:,} rows"))
795
+ print(info("-" * 80))
796
+
797
+ for idx, index_table in enumerate(index_tables):
798
+ index_count = row[idx + 1]
799
+ if index_count == main_count:
800
+ print(f"{success('✓')} {index_table}: {bold(f'{index_count:,}')} rows")
801
+ else:
802
+ print(f"{error('✗ MISMATCH')} {index_table}: {bold(f'{index_count:,}')} rows")
803
+ mismatches.append((index_table, index_count))
804
+
805
+ print(info("=" * 80))
806
+
807
+ if mismatches:
808
+ print(f"❌ FAILED: {len(mismatches)} index table(s) have mismatched counts!")
809
+ else:
810
+ print(f"✅ PASSED: All index tables match ({main_count:,} rows)")
811
+ print()
812
+
813
+ # Switch back if needed
814
+ if current_db and current_db != database:
815
+ self.client.execute(f"USE {current_db}")
816
+
817
+ except Exception as e:
818
+ print(f"❌ Error: {e}")
819
+
820
+ def do_show_ivf_status(self, arg):
821
+ """
822
+ Show IVF index centroids building status.
823
+
824
+ Usage:
825
+ show_ivf_status [database] - Show compact summary
826
+ show_ivf_status [database] -v - Show detailed view
827
+ show_ivf_status [database] -t table - Filter by table name
828
+
829
+ Example:
830
+ show_ivf_status
831
+ show_ivf_status test -v
832
+ show_ivf_status test -t my_table
833
+ """
834
+ # Parse arguments
835
+ args = arg.strip().rstrip(';').split() if arg.strip() else []
836
+ database = None
837
+ verbose = False
838
+ filter_table = None
839
+
840
+ i = 0
841
+ while i < len(args):
842
+ if args[i] == '-v':
843
+ verbose = True
844
+ elif args[i] == '-t' and i + 1 < len(args):
845
+ filter_table = args[i + 1]
846
+ i += 1
847
+ elif not database:
848
+ database = args[i]
849
+ i += 1
850
+
851
+ if not database:
852
+ database = self.current_database
853
+
854
+ if not database:
855
+ print("❌ No database specified and no current database set.")
856
+ return
857
+
858
+ if not self.client:
859
+ print("❌ Not connected. Use 'connect' first.")
860
+ return
861
+
862
+ try:
863
+ # Get all tables with IVF indexes and their column information
864
+ # Group by table and index name to avoid duplicates from multiple index table types
865
+ sql = """
866
+ SELECT
867
+ mo_tables.relname,
868
+ mo_indexes.name,
869
+ mo_indexes.column_name,
870
+ MAX(mo_indexes.algo_table_type) as algo_table_type
871
+ FROM mo_catalog.mo_indexes
872
+ JOIN mo_catalog.mo_tables ON mo_indexes.table_id = mo_tables.rel_id
873
+ WHERE mo_tables.reldatabase = ?
874
+ AND mo_indexes.algo LIKE '%ivf%'
875
+ GROUP BY mo_tables.relname, mo_indexes.name, mo_indexes.column_name
876
+ ORDER BY mo_tables.relname, mo_indexes.name
877
+ """
878
+ result = self.client.execute(sql, (database,))
879
+ index_info = result.fetchall()
880
+
881
+ if not index_info:
882
+ print(f"⚠️ No IVF indexes found in database '{database}'")
883
+ return
884
+
885
+ # Filter by table if specified
886
+ if filter_table:
887
+ index_info = [row for row in index_info if row[0] == filter_table]
888
+ if not index_info:
889
+ print(f"⚠️ No IVF indexes found for table '{filter_table}'")
890
+ return
891
+
892
+ print(f"\n📊 IVF Index Status in '{database}':")
893
+ print("=" * 150)
894
+
895
+ if not verbose:
896
+ # Compact table view
897
+ print(
898
+ f"{'Table':<30} | {'Index':<25} | {'Column':<20} | {'Centroids':<10} | {'Vectors':<12} | {'Balance':<10} | {'Status':<15}"
899
+ )
900
+ print("-" * 150)
901
+
902
+ total_ivf_indexes = 0
903
+
904
+ # For each index, get its stats using SDK API
905
+ for row in index_info:
906
+ table_name = row[0]
907
+ index_name = row[1]
908
+ column_name = row[2]
909
+ algo_table_type = row[3] if len(row) > 3 else None
910
+
911
+ try:
912
+ # Use SDK's get_ivf_stats method with column name
913
+ stats = self.client.vector_ops.get_ivf_stats(table_name, column_name)
914
+
915
+ if stats and 'index_tables' in stats:
916
+ # Show centroid distribution
917
+ if 'distribution' in stats:
918
+ dist = stats['distribution']
919
+ centroid_counts = dist.get('centroid_count', [])
920
+
921
+ if centroid_counts:
922
+ total_centroids = len(centroid_counts)
923
+ total_vectors = sum(centroid_counts)
924
+ min_count = min(centroid_counts)
925
+ max_count = max(centroid_counts)
926
+ avg_count = total_vectors / total_centroids if total_centroids > 0 else 0
927
+ balance = max_count / min_count if min_count > 0 else 0
928
+
929
+ if verbose:
930
+ print(f"\nTable: {table_name} | Index: {index_name} | Column: {column_name}")
931
+ print("-" * 150)
932
+
933
+ # Show physical tables
934
+ index_tables = stats['index_tables']
935
+ print("Physical Tables:")
936
+ for table_type, physical_table in index_tables.items():
937
+ print(f" - {table_type:<15}: {physical_table}")
938
+
939
+ print("\nCentroid Distribution:")
940
+ print(f" Total Centroids: {total_centroids}")
941
+ print(f" Total Vectors: {total_vectors:,}")
942
+ print(f" Min/Avg/Max: {min_count} / {avg_count:.1f} / {max_count}")
943
+ print(
944
+ f" Load Balance: {balance:.2f}x" if min_count > 0 else " Load Balance: N/A"
945
+ )
946
+
947
+ # Show top 10 centroids by size
948
+ if len(centroid_counts) > 0:
949
+ centroid_ids = dist.get('centroid_id', [])
950
+ centroid_versions = dist.get('centroid_version', [])
951
+ print("\n Top Centroids (by vector count):")
952
+ centroid_data = list(zip(centroid_ids, centroid_counts, centroid_versions))
953
+ centroid_data.sort(key=lambda x: x[1], reverse=True)
954
+
955
+ for i, (cid, count, version) in enumerate(centroid_data[:10], 1):
956
+ print(f" {i:2}. Centroid {cid}: {count:,} vectors (version {version})")
957
+ else:
958
+ # Compact view
959
+ status = "✓ active"
960
+ print(
961
+ f"{table_name:<30} | {index_name:<25} | {column_name:<20} | {total_centroids:<10} | {total_vectors:<12,} | {balance:<10.2f} | {status:<15}"
962
+ )
963
+
964
+ total_ivf_indexes += 1
965
+ else:
966
+ if verbose:
967
+ print("\nCentroid Distribution: ⚠️ No centroids found (empty index)")
968
+ else:
969
+ print(
970
+ f"{table_name:<30} | {index_name:<25} | {column_name:<20} | {'0':<10} | {'0':<12} | {'N/A':<10} | {'⚠️ empty':<15}"
971
+ )
972
+ else:
973
+ if verbose:
974
+ print("\nCentroid Distribution: ⚠️ No distribution data available")
975
+ else:
976
+ print(
977
+ f"{table_name:<30} | {index_name:<25} | {column_name:<20} | {'?':<10} | {'?':<12} | {'?':<10} | {'⚠️ no data':<15}"
978
+ )
979
+ else:
980
+ # Index exists but no stats available
981
+ if verbose:
982
+ print(f"\nTable: {table_name} | Index: {index_name}")
983
+ print("-" * 150)
984
+ print(f"Status: ⚠️ {algo_table_type if algo_table_type else 'No stats available'}")
985
+ else:
986
+ print(
987
+ f"{table_name:<30} | {index_name:<25} | {column_name:<20} | {'?':<10} | {'?':<12} | {'?':<10} | {'⚠️ no stats':<15}"
988
+ )
989
+
990
+ except Exception as e:
991
+ # If we can't get IVF stats, show the error
992
+ if verbose:
993
+ print(f"\nTable: {table_name} | Index: {index_name}")
994
+ print("-" * 150)
995
+ print(f"Status: ❌ Error - {str(e)}")
996
+ else:
997
+ error_msg = str(e)[:10]
998
+ print(
999
+ f"{table_name:<30} | {index_name:<25} | {column_name:<20} | {'?':<10} | {'?':<12} | {'?':<10} | {'❌ ' + error_msg:<15}"
1000
+ )
1001
+ continue
1002
+
1003
+ print("=" * 150)
1004
+ if total_ivf_indexes == 0:
1005
+ print("⚠️ No accessible IVF indexes found")
1006
+ else:
1007
+ print(f"Total: {total_ivf_indexes} IVF indexes")
1008
+ if not verbose:
1009
+ print(f"\nTip: Use 'show_ivf_status {database} -v' for detailed view with top centroids")
1010
+ print(f" Use 'show_ivf_status {database} -t <table>' to filter by table")
1011
+ print()
1012
+
1013
+ except Exception as e:
1014
+ print(f"❌ Error: {e}")
1015
+
1016
+ def do_show_table_stats(self, arg):
1017
+ """
1018
+ Show table statistics using metadata_scan.
1019
+
1020
+ Usage:
1021
+ show_table_stats <table> [database] - Show table stats summary
1022
+ show_table_stats <table> [database] -t - Include tombstone stats
1023
+ show_table_stats <table> [database] -i idx1,idx2 - Include specific index stats
1024
+ show_table_stats <table> [database] -a - Include all (tombstone + all indexes)
1025
+ show_table_stats <table> [database] -d - Show detailed object list
1026
+
1027
+ Example:
1028
+ show_table_stats my_table
1029
+ show_table_stats my_table test -t
1030
+ show_table_stats my_table test -i idx_vec,idx_name
1031
+ show_table_stats my_table test -a
1032
+ show_table_stats my_table test -a -d
1033
+ """
1034
+ args = arg.strip().rstrip(';').split() if arg.strip() else []
1035
+
1036
+ if not args:
1037
+ print("❌ Usage: show_table_stats <table> [database] [-t] [-i index_names] [-a] [-d]")
1038
+ return
1039
+
1040
+ table_name = args[0]
1041
+ database = None
1042
+ include_tombstone = False
1043
+ include_indexes = None
1044
+ include_all = False
1045
+ show_detail = False
1046
+
1047
+ # Parse remaining arguments
1048
+ i = 1
1049
+ while i < len(args):
1050
+ if args[i] == '-t':
1051
+ include_tombstone = True
1052
+ elif args[i] == '-a':
1053
+ include_all = True
1054
+ elif args[i] == '-d':
1055
+ show_detail = True
1056
+ elif args[i] == '-i' and i + 1 < len(args):
1057
+ include_indexes = [idx.strip() for idx in args[i + 1].split(',')]
1058
+ i += 1
1059
+ elif not database:
1060
+ database = args[i]
1061
+ i += 1
1062
+
1063
+ if not database:
1064
+ database = self.current_database
1065
+
1066
+ if not database:
1067
+ print("❌ No database specified and no current database set.")
1068
+ return
1069
+
1070
+ if not self.client:
1071
+ print("❌ Not connected. Use 'connect' first.")
1072
+ return
1073
+
1074
+ try:
1075
+ # If -a flag is set, get all secondary indexes for the table
1076
+ if include_all:
1077
+ include_tombstone = True
1078
+ # Get all secondary indexes for the table (exclude PRIMARY and UNIQUE)
1079
+ try:
1080
+ index_list = self.client.get_secondary_index_tables(table_name, database)
1081
+ if index_list:
1082
+ # Extract index names from physical table names
1083
+ # Physical table names are like: __mo_index_secondary_xxxxx
1084
+ # We need to get the actual index names from mo_indexes
1085
+ # Filter out PRIMARY, UNIQUE and other system indexes
1086
+ sql = """
1087
+ SELECT DISTINCT mo_indexes.name
1088
+ FROM mo_catalog.mo_indexes
1089
+ JOIN mo_catalog.mo_tables ON mo_indexes.table_id = mo_tables.rel_id
1090
+ WHERE mo_tables.relname = ?
1091
+ AND mo_tables.reldatabase = ?
1092
+ AND mo_indexes.name NOT IN ('PRIMARY', '__mo_rowid_idx')
1093
+ AND mo_indexes.type != 'PRIMARY KEY'
1094
+ AND mo_indexes.type != 'UNIQUE'
1095
+ """
1096
+ result = self.client.execute(sql, (table_name, database))
1097
+ include_indexes = [row[0] for row in result.fetchall()]
1098
+ if not include_indexes:
1099
+ include_indexes = None # No secondary indexes
1100
+ except Exception as e:
1101
+ print(f"⚠️ Warning: Could not fetch index list: {e}")
1102
+ include_indexes = None
1103
+
1104
+ if show_detail:
1105
+ # Check if showing all indexes (-a -d together = hierarchical view)
1106
+ if include_all:
1107
+ # Show hierarchical view: Table -> Index Name -> Physical Table (with type) -> Object List
1108
+ print(f"\n📊 Detailed Table Statistics for '{database}.{table_name}':")
1109
+ print("=" * 150)
1110
+
1111
+ # Helper functions
1112
+ def format_size(size_bytes):
1113
+ if isinstance(size_bytes, str):
1114
+ return size_bytes
1115
+ if size_bytes >= 1024 * 1024:
1116
+ return f"{size_bytes / (1024 * 1024):.2f} MB"
1117
+ elif size_bytes >= 1024:
1118
+ return f"{size_bytes / 1024:.2f} KB"
1119
+ else:
1120
+ return f"{size_bytes} B"
1121
+
1122
+ def deduplicate_objects(objects):
1123
+ seen = set()
1124
+ unique_objects = []
1125
+ for obj in objects:
1126
+ # Handle both dict and MetadataRow objects
1127
+ if hasattr(obj, 'object_name'):
1128
+ obj_name = getattr(obj, 'object_name', None)
1129
+ elif isinstance(obj, dict):
1130
+ obj_name = obj.get('object_name')
1131
+ else:
1132
+ obj_name = None
1133
+ if obj_name and obj_name not in seen:
1134
+ seen.add(obj_name)
1135
+ unique_objects.append(obj)
1136
+ return unique_objects
1137
+
1138
+ # 1. Show main table with aggregated stats
1139
+ try:
1140
+ main_table_objects = self.client.metadata.scan(database, table_name, columns="*")
1141
+ if main_table_objects:
1142
+ unique_objs = deduplicate_objects(list(main_table_objects))
1143
+ total_rows = sum(getattr(obj, 'row_cnt', 0) or 0 for obj in unique_objs)
1144
+ total_null = sum(getattr(obj, 'null_cnt', 0) or 0 for obj in unique_objs)
1145
+ total_origin = sum(getattr(obj, 'origin_size', 0) or 0 for obj in unique_objs)
1146
+ total_compress = sum(getattr(obj, 'compress_size', 0) or 0 for obj in unique_objs)
1147
+
1148
+ print(f"\n{bold('Table:')} {Colors.CYAN}{table_name}{Colors.RESET}")
1149
+ print(
1150
+ f" Objects: {len(unique_objs)} | Rows: {total_rows:,} | Null: {total_null:,} | Original: {format_size(total_origin)} | Compressed: {format_size(total_compress)}"
1151
+ )
1152
+
1153
+ # Show object details
1154
+ if unique_objs:
1155
+ print(f"\n {bold('Objects:')}")
1156
+ print(
1157
+ f" {'Object Name':<50} | {'Rows':<12} | {'Null Cnt':<10} | {'Original Size':<15} | {'Compressed Size':<15}"
1158
+ )
1159
+ print(" " + "-" * 148)
1160
+ for obj in unique_objs:
1161
+ obj_name = getattr(obj, 'object_name', 'N/A')
1162
+ rows = getattr(obj, 'row_cnt', 0) or 0
1163
+ nulls = getattr(obj, 'null_cnt', 0) or 0
1164
+ orig_size = getattr(obj, 'origin_size', 0) or 0
1165
+ comp_size = getattr(obj, 'compress_size', 0) or 0
1166
+ print(
1167
+ f" {obj_name:<50} | {rows:<12,} | {nulls:<10,} | {format_size(orig_size):<15} | {format_size(comp_size):<15}"
1168
+ )
1169
+ except Exception:
1170
+ pass
1171
+
1172
+ # 2. Get all indexes and their physical tables
1173
+ try:
1174
+ indexes = self.client.get_table_indexes_detail(table_name, database)
1175
+
1176
+ if indexes:
1177
+ # Group indexes by index name
1178
+ indexes_by_name = {}
1179
+ for idx in indexes:
1180
+ idx_name = idx['index_name']
1181
+ if idx_name not in indexes_by_name:
1182
+ indexes_by_name[idx_name] = []
1183
+ indexes_by_name[idx_name].append(idx)
1184
+
1185
+ # Display each index with its physical tables
1186
+ for idx_name, idx_tables in indexes_by_name.items():
1187
+ print(f"\n{bold('Index:')} {Colors.CYAN}{idx_name}{Colors.RESET}")
1188
+
1189
+ # Show each physical table for this index
1190
+ for idx_table in idx_tables:
1191
+ physical_table = idx_table['physical_table_name']
1192
+ table_type = idx_table['algo_table_type'] if idx_table['algo_table_type'] else 'index'
1193
+
1194
+ # Get objects for this physical table
1195
+ try:
1196
+ phys_objects = self.client.metadata.scan(database, physical_table, columns="*")
1197
+ if phys_objects:
1198
+ unique_objs = deduplicate_objects(list(phys_objects))
1199
+ total_rows = sum(getattr(obj, 'row_cnt', 0) or 0 for obj in unique_objs)
1200
+ total_null = sum(getattr(obj, 'null_cnt', 0) or 0 for obj in unique_objs)
1201
+ total_origin = sum(getattr(obj, 'origin_size', 0) or 0 for obj in unique_objs)
1202
+ total_compress = sum(
1203
+ getattr(obj, 'compress_size', 0) or 0 for obj in unique_objs
1204
+ )
1205
+
1206
+ # Color code by table type
1207
+ if table_type == 'metadata':
1208
+ type_display = f"{Colors.YELLOW}{table_type}{Colors.RESET}"
1209
+ elif table_type == 'centroids':
1210
+ type_display = f"{Colors.GREEN}{table_type}{Colors.RESET}"
1211
+ elif table_type == 'entries':
1212
+ type_display = f"{Colors.CYAN}{table_type}{Colors.RESET}"
1213
+ else:
1214
+ type_display = table_type
1215
+
1216
+ print(f" └─ Physical Table ({type_display}): {physical_table}")
1217
+ print(
1218
+ f" Objects: {len(unique_objs)} | Rows: {total_rows:,} | Null: {total_null:,} | Original: {format_size(total_origin)} | Compressed: {format_size(total_compress)}"
1219
+ )
1220
+
1221
+ # Show object details
1222
+ if unique_objs:
1223
+ print(f"\n {bold('Objects:')}")
1224
+ print(
1225
+ f" {'Object Name':<50} | {'Rows':<12} | {'Null Cnt':<10} | {'Original Size':<15} | {'Compressed Size':<15}"
1226
+ )
1227
+ print(" " + "-" * 148)
1228
+ for obj in unique_objs:
1229
+ obj_name = getattr(obj, 'object_name', 'N/A')
1230
+ rows = getattr(obj, 'row_cnt', 0) or 0
1231
+ nulls = getattr(obj, 'null_cnt', 0) or 0
1232
+ orig_size = getattr(obj, 'origin_size', 0) or 0
1233
+ comp_size = getattr(obj, 'compress_size', 0) or 0
1234
+ print(
1235
+ f" {obj_name:<50} | {rows:<12,} | {nulls:<10,} | {format_size(orig_size):<15} | {format_size(comp_size):<15}"
1236
+ )
1237
+ except Exception:
1238
+ # If can't get stats for this physical table, skip
1239
+ pass
1240
+ except Exception:
1241
+ pass
1242
+
1243
+ print("\n" + "=" * 150)
1244
+ print()
1245
+ return
1246
+
1247
+ # Regular detailed view (not hierarchical)
1248
+ # Get detailed statistics with object list
1249
+ stats = self.client.metadata.get_table_detail_stats(
1250
+ dbname=database,
1251
+ tablename=table_name,
1252
+ include_tombstone=include_tombstone,
1253
+ include_indexes=include_indexes,
1254
+ )
1255
+
1256
+ if not stats:
1257
+ print(f"⚠️ No statistics available for table '{database}.{table_name}'")
1258
+ return
1259
+
1260
+ print(f"\n📊 Detailed Table Statistics for '{database}.{table_name}':")
1261
+ print("=" * 150)
1262
+
1263
+ # Helper function to format object row
1264
+ def format_obj_row(obj):
1265
+ # Try different possible key names
1266
+ object_name = obj.get('object_name', 'N/A')
1267
+ create_ts = obj.get('create_ts', 'N/A')
1268
+
1269
+ # Safely convert to int or string for formatting
1270
+ def safe_value(value, default=0):
1271
+ # If value is already a string (formatted), return as is
1272
+ if isinstance(value, str):
1273
+ return value
1274
+ try:
1275
+ return int(value) if value is not None else default
1276
+ except (ValueError, TypeError):
1277
+ return default
1278
+
1279
+ rows_cnt = safe_value(obj.get('rows_cnt', obj.get('row_cnt', 0)))
1280
+ null_cnt = safe_value(obj.get('null_cnt', 0))
1281
+
1282
+ # Size fields might already be formatted strings (e.g., "1.5 MB")
1283
+ origin_size = obj.get('origin_size', obj.get('original_size', 0))
1284
+ compress_size = obj.get('compress_size', obj.get('compressed_size', 0))
1285
+
1286
+ # Format row based on whether sizes are numbers or strings
1287
+ if isinstance(rows_cnt, str):
1288
+ rows_str = f"{rows_cnt:<12}"
1289
+ else:
1290
+ rows_str = f"{rows_cnt:<12,}"
1291
+
1292
+ if isinstance(null_cnt, str):
1293
+ null_str = f"{null_cnt:<10}"
1294
+ else:
1295
+ null_str = f"{null_cnt:<10,}"
1296
+
1297
+ if isinstance(origin_size, str):
1298
+ orig_str = f"{origin_size:<15}"
1299
+ else:
1300
+ orig_str = f"{origin_size:<15,}"
1301
+
1302
+ if isinstance(compress_size, str):
1303
+ comp_str = f"{compress_size:<15}"
1304
+ else:
1305
+ comp_str = f"{compress_size:<15,}"
1306
+
1307
+ return f"{object_name:<50} | {create_ts:<20} | {rows_str} | {null_str} | {orig_str} | {comp_str}"
1308
+
1309
+ # Helper function to deduplicate objects by object_name
1310
+ def deduplicate_objects(objects):
1311
+ """Deduplicate objects by object_name, keeping first occurrence"""
1312
+ seen = set()
1313
+ unique_objects = []
1314
+ for obj in objects:
1315
+ obj_name = obj.get('object_name', 'N/A')
1316
+ if obj_name not in seen:
1317
+ seen.add(obj_name)
1318
+ unique_objects.append(obj)
1319
+ return unique_objects
1320
+
1321
+ # Show main table details
1322
+ if table_name in stats:
1323
+ unique_objs = deduplicate_objects(stats[table_name])
1324
+ print(f"\n{Colors.BOLD}Table: {table_name}{Colors.RESET} ({len(unique_objs)} objects)")
1325
+ print("-" * 150)
1326
+ print(
1327
+ f"{'Object Name':<50} | {'Create Time':<20} | {'Rows':<12} | {'Null Cnt':<10} | {'Original Size':<15} | {'Compressed Size':<15}"
1328
+ )
1329
+ print("-" * 150)
1330
+ for obj in unique_objs:
1331
+ print(format_obj_row(obj))
1332
+
1333
+ # Show tombstone details
1334
+ if 'tombstone' in stats:
1335
+ unique_objs = deduplicate_objects(stats['tombstone'])
1336
+ print(f"\n{Colors.BOLD}Tombstone{Colors.RESET} ({len(unique_objs)} objects)")
1337
+ print("-" * 150)
1338
+ print(
1339
+ f"{'Object Name':<50} | {'Create Time':<20} | {'Rows':<12} | {'Null Cnt':<10} | {'Original Size':<15} | {'Compressed Size':<15}"
1340
+ )
1341
+ print("-" * 150)
1342
+ for obj in unique_objs:
1343
+ print(format_obj_row(obj))
1344
+
1345
+ # Show index details
1346
+ for key, objects in stats.items():
1347
+ if key not in [table_name, 'tombstone']:
1348
+ unique_objs = deduplicate_objects(objects)
1349
+ index_name = key.replace(f'{table_name}_', '')
1350
+ print(f"\n{Colors.BOLD}Index: {index_name}{Colors.RESET} ({len(unique_objs)} objects)")
1351
+ print("-" * 150)
1352
+ print(
1353
+ f"{'Object Name':<50} | {'Create Time':<20} | {'Rows':<12} | {'Null Cnt':<10} | {'Original Size':<15} | {'Compressed Size':<15}"
1354
+ )
1355
+ print("-" * 150)
1356
+ for obj in unique_objs:
1357
+ print(format_obj_row(obj))
1358
+
1359
+ print("=" * 150)
1360
+ else:
1361
+ # Get brief statistics (summary)
1362
+ stats = self.client.metadata.get_table_brief_stats(
1363
+ dbname=database,
1364
+ tablename=table_name,
1365
+ include_tombstone=include_tombstone,
1366
+ include_indexes=include_indexes,
1367
+ )
1368
+
1369
+ if not stats:
1370
+ print(f"⚠️ No statistics available for table '{database}.{table_name}'")
1371
+ return
1372
+
1373
+ print(f"\n📊 Table Statistics for '{database}.{table_name}':")
1374
+ print("=" * 120)
1375
+ print(
1376
+ f"{'Component':<30} | {'Objects':<10} | {'Rows':<15} | {'Null Count':<12} | {'Original Size':<15} | {'Compressed Size':<15}"
1377
+ )
1378
+ print("-" * 120)
1379
+
1380
+ # Show main table stats
1381
+ if table_name in stats:
1382
+ table_stats = stats[table_name]
1383
+ print(
1384
+ f"{table_name:<30} | {table_stats['total_objects']:<10} | {table_stats['row_cnt']:<15,} | {table_stats['null_cnt']:<12,} | {table_stats['original_size']:<15} | {table_stats['compress_size']:<15}" # type: ignore[call-overload]
1385
+ )
1386
+
1387
+ # Show tombstone stats
1388
+ if 'tombstone' in stats:
1389
+ tomb_stats = stats['tombstone']
1390
+ print(
1391
+ f"{' └─ tombstone':<30} | {tomb_stats['total_objects']:<10} | {tomb_stats['row_cnt']:<15,} | {tomb_stats['null_cnt']:<12,} | {tomb_stats['original_size']:<15} | {tomb_stats['compress_size']:<15}" # type: ignore[call-overload]
1392
+ )
1393
+
1394
+ # Show index stats
1395
+ for key, value in stats.items():
1396
+ if key not in [table_name, 'tombstone']:
1397
+ index_name = key.replace(f'{table_name}_', '') # Clean up index name if prefixed
1398
+ print(
1399
+ f"{' └─ index: ' + index_name:<30} | {value['total_objects']:<10} | {value['row_cnt']:<15,} | {value['null_cnt']:<12,} | {value['original_size']:<15} | {value['compress_size']:<15}" # type: ignore[call-overload]
1400
+ )
1401
+
1402
+ print("=" * 120)
1403
+ print(
1404
+ "\nTip: Use '-t' for tombstone, '-i idx1,idx2' for indexes, '-a' for all, '-d' for detailed object list"
1405
+ )
1406
+ print()
1407
+
1408
+ except Exception as e:
1409
+ print(f"❌ Error: {e}")
1410
+
1411
+ def do_flush_table(self, arg):
1412
+ """Flush table and all its secondary index tables
1413
+
1414
+ Usage:
1415
+ flush_table <table> [database] - Flush main table and all its index tables
1416
+
1417
+ Example:
1418
+ flush_table my_table
1419
+ flush_table my_table test
1420
+
1421
+ Note: Requires sys user privileges
1422
+ """
1423
+ if not arg.strip():
1424
+ print(f"{error('Error:')} Table name is required")
1425
+ print(f"{info('Usage:')} flush_table <table> [database]")
1426
+ return
1427
+
1428
+ # Parse arguments
1429
+ parts = arg.strip().split()
1430
+ if len(parts) < 1:
1431
+ print(f"{error('Error:')} Table name is required")
1432
+ return
1433
+
1434
+ table_name = parts[0].strip().rstrip(';')
1435
+ database_name = self.current_database
1436
+
1437
+ # Check for database parameter
1438
+ if len(parts) > 1:
1439
+ database_name = parts[1].strip().rstrip(';')
1440
+
1441
+ try:
1442
+ print(f"{info('🔄 Flushing table:')} {database_name}.{table_name}")
1443
+
1444
+ # Flush main table
1445
+ try:
1446
+ self.client.moctl.flush_table(database_name, table_name)
1447
+ print(f"{success('✓')} Main table flushed successfully")
1448
+ except Exception as e:
1449
+ print(f"{error('❌')} Failed to flush main table: {e}")
1450
+ return
1451
+
1452
+ # Get all index tables (including IVF/HNSW/Fulltext physical tables)
1453
+ try:
1454
+ indexes = self.client.get_table_indexes_detail(table_name, database_name)
1455
+ if indexes:
1456
+ # Extract all unique physical table names
1457
+ physical_tables = list(set(idx['physical_table_name'] for idx in indexes if idx['physical_table_name']))
1458
+
1459
+ if physical_tables:
1460
+ print(f"{info('📋 Found')} {len(physical_tables)} index physical tables")
1461
+
1462
+ # Group by index name for better display
1463
+ indexes_by_name = {}
1464
+ for idx in indexes:
1465
+ idx_name = idx['index_name']
1466
+ if idx_name not in indexes_by_name:
1467
+ indexes_by_name[idx_name] = []
1468
+ indexes_by_name[idx_name].append(idx)
1469
+
1470
+ # Flush each physical table
1471
+ success_count = 0
1472
+ for idx_name, idx_tables in indexes_by_name.items():
1473
+ print(f"\n{info('📑 Index:')} {idx_name}")
1474
+ for idx_table in idx_tables:
1475
+ physical_table = idx_table['physical_table_name']
1476
+ table_type = idx_table['algo_table_type'] if idx_table['algo_table_type'] else 'index'
1477
+ try:
1478
+ self.client.moctl.flush_table(database_name, physical_table)
1479
+ print(f" {success('✓')} {table_type}: {physical_table}")
1480
+ success_count += 1
1481
+ except Exception as e:
1482
+ print(f" {error('❌')} {table_type}: {physical_table} - {e}")
1483
+
1484
+ print(f"\n{info('📊 Summary:')}")
1485
+ print(f" Main table: {success('✓')} flushed")
1486
+ print(f" Index physical tables: {success_count}/{len(physical_tables)} flushed successfully")
1487
+ else:
1488
+ print(f"{info('ℹ️')} No index physical tables found")
1489
+ print(f"{info('📊 Summary:')} Main table: {success('✓')} flushed")
1490
+ else:
1491
+ print(f"{info('ℹ️')} No indexes found")
1492
+ print(f"{info('📊 Summary:')} Main table: {success('✓')} flushed")
1493
+
1494
+ except Exception as e:
1495
+ print(f"{warning('⚠️')} Failed to get index tables: {e}")
1496
+ print(f"{info('📊 Summary:')} Main table: {success('✓')} flushed (index tables not processed)")
1497
+
1498
+ except Exception as e:
1499
+ print(f"{error('❌ Error:')} {e}")
1500
+
1501
+ def do_tables(self, arg):
1502
+ """Show all tables in current database or specified database
1503
+
1504
+ Usage:
1505
+ tables - Show all tables in current database
1506
+ tables <database> - Show all tables in specified database
1507
+
1508
+ Example:
1509
+ tables
1510
+ tables test
1511
+ """
1512
+ if not self.client:
1513
+ print(f"{error('❌ Error:')} Not connected. Use 'connect' first.")
1514
+ return
1515
+
1516
+ # Parse arguments
1517
+ database_name = self.current_database
1518
+ if arg.strip():
1519
+ database_name = arg.strip().rstrip(';')
1520
+
1521
+ if not database_name:
1522
+ print(f"{error('❌ Error:')} No database specified and no current database set.")
1523
+ print(f"{info('Usage:')} tables [database]")
1524
+ return
1525
+
1526
+ try:
1527
+ # Execute SHOW TABLES
1528
+ sql = f"SHOW TABLES FROM `{database_name}`"
1529
+ result = self.client.execute(sql)
1530
+ rows = result.fetchall()
1531
+
1532
+ if rows:
1533
+ print(f"\n{header('📋 Tables in database')} '{database_name}':")
1534
+ print("=" * 80)
1535
+
1536
+ # Print table names
1537
+ for i, row in enumerate(rows, 1):
1538
+ table_name = row[0]
1539
+ print(f"{i:4}. {table_name}")
1540
+
1541
+ print("=" * 80)
1542
+ print(f"{info('Total:')} {len(rows)} tables")
1543
+ else:
1544
+ print(f"{warning('⚠️')} No tables found in database '{database_name}'")
1545
+ print()
1546
+
1547
+ except Exception as e:
1548
+ print(f"{error('❌ Error:')} {e}")
1549
+
1550
+ def do_databases(self, arg):
1551
+ """Show all databases
1552
+
1553
+ Usage:
1554
+ databases - Show all databases
1555
+
1556
+ Example:
1557
+ databases
1558
+ """
1559
+ if not self.client:
1560
+ print(f"{error('❌ Error:')} Not connected. Use 'connect' first.")
1561
+ return
1562
+
1563
+ try:
1564
+ # Execute SHOW DATABASES
1565
+ result = self.client.execute("SHOW DATABASES")
1566
+ rows = result.fetchall()
1567
+
1568
+ if rows:
1569
+ print(f"\n{header('🗄️ Databases:')}")
1570
+ print("=" * 80)
1571
+
1572
+ # Print database names with current database highlighted
1573
+ for i, row in enumerate(rows, 1):
1574
+ db_name = row[0]
1575
+ if db_name == self.current_database:
1576
+ print(f"{i:4}. {success(db_name)} {info('← current')}")
1577
+ else:
1578
+ print(f"{i:4}. {db_name}")
1579
+
1580
+ print("=" * 80)
1581
+ print(f"{info('Total:')} {len(rows)} databases")
1582
+ else:
1583
+ print(f"{warning('⚠️')} No databases found")
1584
+ print()
1585
+
1586
+ except Exception as e:
1587
+ print(f"{error('❌ Error:')} {e}")
1588
+
1589
+ def do_history(self, arg):
1590
+ """Show command history
1591
+
1592
+ Usage:
1593
+ history - Show last 20 commands
1594
+ history <n> - Show last n commands
1595
+ history -c - Clear history
1596
+
1597
+ Example:
1598
+ history
1599
+ history 50
1600
+ history -c
1601
+ """
1602
+ if not PROMPT_TOOLKIT_AVAILABLE or not self.session:
1603
+ print(f"{warning('⚠️')} History is only available when prompt_toolkit is installed.")
1604
+ print(f"{info('Tip:')} Install with: pip install prompt_toolkit")
1605
+ return
1606
+
1607
+ # Parse arguments
1608
+ arg = arg.strip()
1609
+
1610
+ # Clear history
1611
+ if arg == '-c':
1612
+ try:
1613
+ history_file = Path.home() / '.mo_diag_history'
1614
+ if history_file.exists():
1615
+ history_file.unlink()
1616
+ print(f"{success('✓')} History cleared")
1617
+ else:
1618
+ print(f"{info('ℹ️')} No history file found")
1619
+ # Recreate the history
1620
+ self.session.history = FileHistory(str(history_file))
1621
+ except Exception as e:
1622
+ print(f"{error('❌ Error:')} Failed to clear history: {e}")
1623
+ return
1624
+
1625
+ # Determine how many commands to show
1626
+ try:
1627
+ count = int(arg) if arg else 20
1628
+ except ValueError:
1629
+ print(f"{error('❌ Error:')} Invalid number: {arg}")
1630
+ return
1631
+
1632
+ # Get history
1633
+ try:
1634
+ history_file = Path.home() / '.mo_diag_history'
1635
+ if not history_file.exists():
1636
+ print(f"{info('ℹ️')} No history yet")
1637
+ return
1638
+
1639
+ # Read history file
1640
+ with open(history_file, 'r', encoding='utf-8') as f:
1641
+ lines = f.readlines()
1642
+
1643
+ # Parse prompt_toolkit FileHistory format:
1644
+ # Empty line
1645
+ # # timestamp (comment)
1646
+ # +command
1647
+ # Empty line
1648
+ commands = []
1649
+ seen = set()
1650
+ for line in lines:
1651
+ line = line.strip()
1652
+ # Skip empty lines and timestamp comments
1653
+ if not line or line.startswith('#'):
1654
+ continue
1655
+ # Commands start with '+'
1656
+ if line.startswith('+'):
1657
+ cmd = line[1:] # Remove the '+' prefix
1658
+ if cmd and cmd not in seen:
1659
+ commands.append(cmd)
1660
+ seen.add(cmd)
1661
+
1662
+ # Show last N commands
1663
+ if commands:
1664
+ start_idx = max(0, len(commands) - count)
1665
+ print(f"\n{header('📜 Command History')} (last {min(count, len(commands))} commands):")
1666
+ print("=" * 80)
1667
+
1668
+ for i, cmd in enumerate(commands[start_idx:], start=start_idx + 1):
1669
+ print(f"{i:4}. {cmd}")
1670
+
1671
+ print("=" * 80)
1672
+ print(f"{info('Total:')} {len(commands)} commands in history\n")
1673
+ else:
1674
+ print(f"{info('ℹ️')} No history yet")
1675
+
1676
+ except Exception as e:
1677
+ print(f"{error('❌ Error:')} Failed to read history: {e}")
1678
+
1679
+ def do_sql(self, arg):
1680
+ """
1681
+ Execute a SQL query directly.
1682
+
1683
+ Usage: sql <SQL statement>
1684
+ Example: sql SELECT COUNT(*) FROM cms_all_content_chunk_info
1685
+ """
1686
+ if not arg:
1687
+ print("❌ Usage: sql <SQL statement>")
1688
+ return
1689
+
1690
+ if not self.client:
1691
+ print("❌ Not connected. Use 'connect' first.")
1692
+ return
1693
+
1694
+ try:
1695
+ result = self.client.execute(arg)
1696
+ rows = result.fetchall()
1697
+
1698
+ if rows:
1699
+ # Print column headers if available
1700
+ try:
1701
+ # Try to get column names from result
1702
+ if hasattr(result, 'keys') and callable(result.keys):
1703
+ headers = result.keys()
1704
+ elif hasattr(result, 'columns'):
1705
+ headers = result.columns
1706
+ elif hasattr(result, '_metadata') and hasattr(result._metadata, 'keys'):
1707
+ headers = result._metadata.keys
1708
+ else:
1709
+ # Fallback: use column numbers
1710
+ headers = [f"col{i}" for i in range(len(rows[0]))]
1711
+
1712
+ print("\n" + " | ".join(str(h) for h in headers))
1713
+ print("-" * (sum(len(str(h)) for h in headers) + len(headers) * 3))
1714
+ except Exception:
1715
+ # If we can't get headers, just skip them
1716
+ pass
1717
+
1718
+ # Print rows
1719
+ for row in rows:
1720
+ print(" | ".join(str(v) if v is not None else "NULL" for v in row))
1721
+
1722
+ print(f"\n{len(rows)} row(s) returned\n")
1723
+ else:
1724
+ print("✓ Query executed successfully (0 rows returned)\n")
1725
+
1726
+ except Exception as e:
1727
+ print(f"❌ Error: {e}")
1728
+
1729
+ def do_exit(self, arg):
1730
+ """Exit the interactive tool."""
1731
+ print("\n👋 Goodbye!\n")
1732
+ if self.client:
1733
+ try:
1734
+ self.client.disconnect()
1735
+ except Exception:
1736
+ pass
1737
+ return True
1738
+
1739
+ def do_quit(self, arg):
1740
+ """Exit the interactive tool."""
1741
+ return self.do_exit(arg)
1742
+
1743
+ def do_EOF(self, arg):
1744
+ """Handle Ctrl+D to exit."""
1745
+ print()
1746
+ return self.do_exit(arg)
1747
+
1748
+ def emptyline(self):
1749
+ """Do nothing on empty line."""
1750
+ pass
1751
+
1752
+
1753
+ def start_interactive_tool(host='localhost', port=6001, user='root', password='111', database=None, log_level='ERROR'):
1754
+ """
1755
+ Start the interactive diagnostic tool.
1756
+
1757
+ Args:
1758
+ host: Database host
1759
+ port: Database port
1760
+ user: Database user
1761
+ password: Database password
1762
+ database: Database name (optional)
1763
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Default: ERROR
1764
+ """
1765
+ import logging
1766
+
1767
+ # Set logging level BEFORE creating Client
1768
+ level = getattr(logging, log_level.upper(), logging.ERROR)
1769
+
1770
+ # Set for matrixone logger and all its handlers
1771
+ mo_logger = logging.getLogger('matrixone')
1772
+ mo_logger.setLevel(level)
1773
+
1774
+ # Set level for all existing handlers
1775
+ for handler in mo_logger.handlers:
1776
+ handler.setLevel(level)
1777
+
1778
+ # Create client with minimal SQL logging
1779
+ sql_log_mode = 'off' if level >= logging.ERROR else 'auto'
1780
+ client = Client(sql_log_mode=sql_log_mode)
1781
+
1782
+ # Set level again after client creation (in case client adds handlers)
1783
+ mo_logger.setLevel(level)
1784
+ for handler in mo_logger.handlers:
1785
+ handler.setLevel(level)
1786
+
1787
+ try:
1788
+ client.connect(host=host, port=port, user=user, password=password, database=database)
1789
+ print(f"✓ Connected to {host}:{port}" + (f" (database: {database})" if database else ""))
1790
+ except Exception as e:
1791
+ print(f"⚠️ Failed to connect: {e}")
1792
+ print("You can connect manually using the 'connect' command.\n")
1793
+ client = None
1794
+
1795
+ cli = MatrixOneCLI(client)
1796
+ cli.cmdloop()
1797
+
1798
+
1799
+ def main_cli():
1800
+ """
1801
+ Main entry point for the CLI tool when installed via pip.
1802
+ This function is called when running 'mo-diag' command.
1803
+
1804
+ Supports both interactive and non-interactive modes:
1805
+ - Interactive: mo-diag (enters interactive shell)
1806
+ - Non-interactive: mo-diag -c "show_ivf_status test" (executes single command and exits)
1807
+ """
1808
+ import argparse
1809
+
1810
+ parser = argparse.ArgumentParser(
1811
+ description='MatrixOne Interactive Diagnostic Tool',
1812
+ epilog='''
1813
+ Examples:
1814
+ # Interactive mode
1815
+ mo-diag --host localhost --port 6001 --user root --password 111
1816
+
1817
+ # Non-interactive mode - execute single command
1818
+ mo-diag -d test -c "show_ivf_status"
1819
+ mo-diag -d test -c "show_table_stats my_table -a"
1820
+ mo-diag -d test -c "sql SELECT COUNT(*) FROM my_table"
1821
+ ''',
1822
+ )
1823
+ parser.add_argument('--host', default='localhost', help='Database host (default: localhost)')
1824
+ parser.add_argument('--port', type=int, default=6001, help='Database port (default: 6001)')
1825
+ parser.add_argument('--user', default='root', help='Database user (default: root)')
1826
+ parser.add_argument('--password', default='111', help='Database password (default: 111)')
1827
+ parser.add_argument('--database', '-d', help='Database name (optional)')
1828
+ parser.add_argument(
1829
+ '--log-level',
1830
+ default='ERROR',
1831
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
1832
+ help='Logging level (default: ERROR)',
1833
+ )
1834
+ parser.add_argument('--command', '-c', help='Execute a single command and exit (non-interactive mode)')
1835
+
1836
+ args = parser.parse_args()
1837
+
1838
+ # Non-interactive mode: execute single command
1839
+ if args.command:
1840
+ import logging
1841
+
1842
+ # Set logging level
1843
+ level = getattr(logging, args.log_level.upper(), logging.ERROR)
1844
+ mo_logger = logging.getLogger('matrixone')
1845
+ mo_logger.setLevel(level)
1846
+ for handler in mo_logger.handlers:
1847
+ handler.setLevel(level)
1848
+
1849
+ # Create client
1850
+ sql_log_mode = 'off' if level >= logging.ERROR else 'auto'
1851
+ client = Client(sql_log_mode=sql_log_mode)
1852
+
1853
+ # Set level again after client creation
1854
+ mo_logger.setLevel(level)
1855
+ for handler in mo_logger.handlers:
1856
+ handler.setLevel(level)
1857
+
1858
+ # Connect
1859
+ try:
1860
+ client.connect(host=args.host, port=args.port, user=args.user, password=args.password, database=args.database)
1861
+ except Exception as e:
1862
+ print(f"❌ Failed to connect: {e}")
1863
+ import sys
1864
+
1865
+ sys.exit(1)
1866
+
1867
+ # Create CLI instance and execute command
1868
+ cli = MatrixOneCLI(client)
1869
+ try:
1870
+ cli.onecmd(args.command)
1871
+ except Exception as e:
1872
+ print(f"❌ Command execution failed: {e}")
1873
+ import sys
1874
+
1875
+ sys.exit(1)
1876
+ finally:
1877
+ if client:
1878
+ try:
1879
+ client.disconnect()
1880
+ except Exception:
1881
+ pass
1882
+ else:
1883
+ # Interactive mode
1884
+ start_interactive_tool(
1885
+ host=args.host,
1886
+ port=args.port,
1887
+ user=args.user,
1888
+ password=args.password,
1889
+ database=args.database,
1890
+ log_level=args.log_level,
1891
+ )
1892
+
1893
+
1894
+ if __name__ == '__main__':
1895
+ main_cli()