Skip to content

Commit fb8d88c

Browse files
authored
Fix percona migration (#78)
* Add unittest for "IF (NOT) EXISTS" * Support Percona-style migration to add a column * Review comments
1 parent a4b1576 commit fb8d88c

File tree

3 files changed

+174
-22
lines changed

3 files changed

+174
-22
lines changed

mysql_ch_replicator/converter.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,24 @@ def __basic_validate_query(self, mysql_query):
354354
if mysql_query.find(';') != -1:
355355
raise Exception('multi-query statement not supported')
356356
return mysql_query
357+
358+
def get_db_and_table_name(self, token, db_name):
359+
if '.' in token:
360+
db_name, table_name = token.split('.')
361+
else:
362+
table_name = token
363+
db_name = strip_sql_name(db_name)
364+
table_name = strip_sql_name(table_name)
365+
if self.db_replicator:
366+
if db_name == self.db_replicator.database:
367+
db_name = self.db_replicator.target_database
368+
matches_config = (
369+
self.db_replicator.config.is_database_matches(db_name)
370+
and self.db_replicator.config.is_table_matches(table_name))
371+
else:
372+
matches_config = True
373+
374+
return db_name, table_name, matches_config
357375

358376
def convert_alter_query(self, mysql_query, db_name):
359377
mysql_query = self.__basic_validate_query(mysql_query)
@@ -365,21 +383,10 @@ def convert_alter_query(self, mysql_query, db_name):
365383
if tokens[1].lower() != 'table':
366384
raise Exception('wrong query')
367385

368-
table_name = tokens[2]
369-
if table_name.find('.') != -1:
370-
db_name, table_name = table_name.split('.')
386+
db_name, table_name, matches_config = self.get_db_and_table_name(tokens[2], db_name)
371387

372-
if self.db_replicator:
373-
if not self.db_replicator.config.is_database_matches(db_name):
374-
return
375-
if not self.db_replicator.config.is_table_matches(table_name):
376-
return
377-
378-
db_name = strip_sql_name(db_name)
379-
if self.db_replicator and db_name == self.db_replicator.database:
380-
db_name = self.db_replicator.target_database
381-
382-
table_name = strip_sql_name(table_name)
388+
if not matches_config:
389+
return
383390

384391
subqueries = ' '.join(tokens[3:])
385392
subqueries = split_high_level(subqueries, ',')

mysql_ch_replicator/db_replicator.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -477,14 +477,18 @@ def handle_query_event(self, event: LogEvent):
477477
logger.debug(f'processing query event: {event.transaction_id}, query: {event.records}')
478478
query = strip_sql_comments(event.records)
479479
if query.lower().startswith('alter'):
480+
self.upload_records()
480481
self.handle_alter_query(query, event.db_name)
481482
if query.lower().startswith('create table'):
482483
self.handle_create_table_query(query, event.db_name)
483484
if query.lower().startswith('drop table'):
485+
self.upload_records()
484486
self.handle_drop_table_query(query, event.db_name)
487+
if query.lower().startswith('rename table'):
488+
self.upload_records()
489+
self.handle_rename_table_query(query, event.db_name)
485490

486491
def handle_alter_query(self, query, db_name):
487-
self.upload_records()
488492
self.converter.convert_alter_query(query, db_name)
489493

490494
def handle_create_table_query(self, query, db_name):
@@ -509,17 +513,41 @@ def handle_drop_table_query(self, query, db_name):
509513
if len(tokens) != 3:
510514
raise Exception('wrong token count', query)
511515

512-
table_name = tokens[2]
513-
if '.' in table_name:
514-
db_name, table_name = table_name.split('.')
515-
if db_name == self.database:
516-
db_name = self.target_database
517-
table_name = strip_sql_name(table_name)
518-
db_name = strip_sql_name(db_name)
516+
db_name, table_name, matches_config = self.converter.get_db_and_table_name(tokens[2], db_name)
517+
if not matches_config:
518+
return
519+
519520
if table_name in self.state.tables_structure:
520521
self.state.tables_structure.pop(table_name)
521522
self.clickhouse_api.execute_command(f'DROP TABLE {"IF EXISTS" if if_exists else ""} {db_name}.{table_name}')
522523

524+
def handle_rename_table_query(self, query, db_name):
525+
tokens = query.split()
526+
if tokens[0].lower() != 'rename' or tokens[1].lower() != 'table':
527+
raise Exception('wrong rename table query', query)
528+
529+
ch_clauses = []
530+
for rename_clause in ' '.join(tokens[2:]).split(','):
531+
tokens = rename_clause.split()
532+
533+
if len(tokens) != 3:
534+
raise Exception('wrong token count', query)
535+
if tokens[1].lower() != 'to':
536+
raise Exception('"to" keyword expected', query)
537+
538+
src_db_name, src_table_name, matches_config = self.converter.get_db_and_table_name(tokens[0], db_name)
539+
dest_db_name, dest_table_name, _ = self.converter.get_db_and_table_name(tokens[2], db_name)
540+
if not matches_config:
541+
return
542+
543+
if src_db_name != self.target_database or dest_db_name != self.target_database:
544+
raise Exception('cross databases table renames not implemented', tokens)
545+
if src_table_name in self.state.tables_structure:
546+
self.state.tables_structure[dest_table_name] = self.state.tables_structure.pop(src_table_name)
547+
548+
ch_clauses.append(f"{src_db_name}.{src_table_name} TO {dest_db_name}.{dest_table_name}")
549+
self.clickhouse_api.execute_command(f'RENAME TABLE {", ".join(ch_clauses)}')
550+
523551
def log_stats_if_required(self):
524552
curr_time = time.time()
525553
if curr_time - self.last_dump_stats_time < DbReplicator.STATS_DUMP_INTERVAL:

test_mysql_ch_replicator.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,122 @@ def test_string_primary_key(monkeypatch):
10571057
binlog_replicator_runner.stop()
10581058

10591059

1060+
def test_if_exists_if_not_exists(monkeypatch):
1061+
monkeypatch.setattr(DbReplicator, 'INITIAL_REPLICATION_BATCH_SIZE', 1)
1062+
1063+
cfg = config.Settings()
1064+
cfg.load(CONFIG_FILE)
1065+
1066+
mysql = mysql_api.MySQLApi(
1067+
database=None,
1068+
mysql_settings=cfg.mysql,
1069+
)
1070+
1071+
ch = clickhouse_api.ClickhouseApi(
1072+
database=TEST_DB_NAME,
1073+
clickhouse_settings=cfg.clickhouse,
1074+
)
1075+
1076+
prepare_env(cfg, mysql, ch)
1077+
1078+
binlog_replicator_runner = BinlogReplicatorRunner()
1079+
binlog_replicator_runner.run()
1080+
db_replicator_runner = DbReplicatorRunner(TEST_DB_NAME)
1081+
db_replicator_runner.run()
1082+
1083+
assert_wait(lambda: TEST_DB_NAME in ch.get_databases())
1084+
1085+
mysql.execute(f"CREATE TABLE IF NOT EXISTS {TEST_DB_NAME}.{TEST_TABLE_NAME} (id int NOT NULL, PRIMARY KEY(id));")
1086+
mysql.execute(f"CREATE TABLE IF NOT EXISTS {TEST_TABLE_NAME} (id int NOT NULL, PRIMARY KEY(id));")
1087+
mysql.execute(f"CREATE TABLE IF NOT EXISTS {TEST_DB_NAME}.{TEST_TABLE_NAME_2} (id int NOT NULL, PRIMARY KEY(id));")
1088+
mysql.execute(f"CREATE TABLE IF NOT EXISTS {TEST_TABLE_NAME_2} (id int NOT NULL, PRIMARY KEY(id));")
1089+
mysql.execute(f"DROP TABLE IF EXISTS {TEST_DB_NAME}.{TEST_TABLE_NAME};")
1090+
mysql.execute(f"DROP TABLE IF EXISTS {TEST_TABLE_NAME};")
1091+
1092+
ch.execute_command(f'USE {TEST_DB_NAME}')
1093+
1094+
assert_wait(lambda: TEST_TABLE_NAME_2 in ch.get_tables())
1095+
assert_wait(lambda: TEST_TABLE_NAME not in ch.get_tables())
1096+
1097+
db_replicator_runner.stop()
1098+
binlog_replicator_runner.stop()
1099+
1100+
1101+
def test_percona_migration(monkeypatch):
1102+
monkeypatch.setattr(DbReplicator, 'INITIAL_REPLICATION_BATCH_SIZE', 1)
1103+
1104+
cfg = config.Settings()
1105+
cfg.load(CONFIG_FILE)
1106+
1107+
mysql = mysql_api.MySQLApi(
1108+
database=None,
1109+
mysql_settings=cfg.mysql,
1110+
)
1111+
1112+
ch = clickhouse_api.ClickhouseApi(
1113+
database=TEST_DB_NAME,
1114+
clickhouse_settings=cfg.clickhouse,
1115+
)
1116+
1117+
prepare_env(cfg, mysql, ch)
1118+
1119+
mysql.execute(f'''
1120+
CREATE TABLE {TEST_TABLE_NAME} (
1121+
`id` int NOT NULL,
1122+
PRIMARY KEY (`id`));
1123+
''')
1124+
1125+
mysql.execute(
1126+
f"INSERT INTO {TEST_TABLE_NAME} (id) VALUES (42)",
1127+
commit=True,
1128+
)
1129+
1130+
binlog_replicator_runner = BinlogReplicatorRunner()
1131+
binlog_replicator_runner.run()
1132+
db_replicator_runner = DbReplicatorRunner(TEST_DB_NAME)
1133+
db_replicator_runner.run()
1134+
1135+
assert_wait(lambda: TEST_DB_NAME in ch.get_databases())
1136+
1137+
ch.execute_command(f'USE {TEST_DB_NAME}')
1138+
1139+
assert_wait(lambda: TEST_TABLE_NAME in ch.get_tables())
1140+
assert_wait(lambda: len(ch.select(TEST_TABLE_NAME)) == 1)
1141+
1142+
# Perform 'pt-online-schema-change' style migration to add a column
1143+
# This is a subset of what happens when the following command is run:
1144+
# pt-online-schema-change --alter "ADD COLUMN c1 INT" D=$TEST_DB_NAME,t=$TEST_TABLE_NAME,h=0.0.0.0,P=3306,u=root,p=admin --execute
1145+
mysql.execute(f'''
1146+
CREATE TABLE `{TEST_DB_NAME}`.`_{TEST_TABLE_NAME}_new` (
1147+
`id` int NOT NULL,
1148+
PRIMARY KEY (`id`)
1149+
)''')
1150+
1151+
mysql.execute(
1152+
f"ALTER TABLE `{TEST_DB_NAME}`.`_{TEST_TABLE_NAME}_new` ADD COLUMN c1 INT;")
1153+
1154+
mysql.execute(
1155+
f"INSERT LOW_PRIORITY IGNORE INTO `{TEST_DB_NAME}`.`_{TEST_TABLE_NAME}_new` (`id`) SELECT `id` FROM `{TEST_DB_NAME}`.`{TEST_TABLE_NAME}` LOCK IN SHARE MODE;",
1156+
commit=True,
1157+
)
1158+
1159+
mysql.execute(
1160+
f"RENAME TABLE `{TEST_DB_NAME}`.`{TEST_TABLE_NAME}` TO `{TEST_DB_NAME}`.`_{TEST_TABLE_NAME}_old`, `{TEST_DB_NAME}`.`_{TEST_TABLE_NAME}_new` TO `{TEST_DB_NAME}`.`{TEST_TABLE_NAME}`;")
1161+
1162+
mysql.execute(
1163+
f"DROP TABLE IF EXISTS `{TEST_DB_NAME}`.`_{TEST_TABLE_NAME}_old`;")
1164+
1165+
mysql.execute(
1166+
f"INSERT INTO {TEST_TABLE_NAME} (id, c1) VALUES (43, 1)",
1167+
commit=True,
1168+
)
1169+
1170+
assert_wait(lambda: len(ch.select(TEST_TABLE_NAME)) == 2)
1171+
1172+
db_replicator_runner.stop()
1173+
binlog_replicator_runner.stop()
1174+
1175+
10601176
def test_parse_mysql_table_structure():
10611177
query = "CREATE TABLE IF NOT EXISTS user_preferences_portal (\n\t\t\tid char(36) NOT NULL,\n\t\t\tcategory varchar(50) DEFAULT NULL,\n\t\t\tdeleted tinyint(1) DEFAULT 0,\n\t\t\tdate_entered datetime DEFAULT NULL,\n\t\t\tdate_modified datetime DEFAULT NULL,\n\t\t\tassigned_user_id char(36) DEFAULT NULL,\n\t\t\tcontents longtext DEFAULT NULL\n\t\t ) ENGINE=InnoDB DEFAULT CHARSET=utf8"
10621178

@@ -1065,3 +1181,4 @@ def test_parse_mysql_table_structure():
10651181
structure = converter.parse_mysql_table_structure(query)
10661182

10671183
assert structure.table_name == 'user_preferences_portal'
1184+

0 commit comments

Comments
 (0)