feat(etl): 支持断点续传功能提升数据导入可靠性
- run_etl.py中新增resume相关参数支持断点续传和手动起始位置 - reader.py中修改iter_hk_songs_batches函数,改用id游标替代offset分页 - runner.py新增断点状态文件管理功能,支持状态读写与断点继续 - run函数接受断点续传参数并根据状态文件恢复进度 - 处理每批次导入成功后保存断点状态,确保可中断恢复 - 新增单元测试覆盖断点续传逻辑,验证正确使用start_after_id参数 - 调整接口参数传递与兼容性,保持向后兼容性和灵活性
Showing
5 changed files
with
127 additions
and
10 deletions
| ... | @@ -8,11 +8,12 @@ SELECT id, name, lyricist, composer, audio_url, lyrics_url, | ... | @@ -8,11 +8,12 @@ SELECT id, name, lyricist, composer, audio_url, lyrics_url, |
| 8 | cover_url, singer, issue_time, source_song_id, song_time | 8 | cover_url, singer, issue_time, source_song_id, song_time |
| 9 | FROM hk_songs_test | 9 | FROM hk_songs_test |
| 10 | WHERE deleted = '0' | 10 | WHERE deleted = '0' |
| 11 | AND id > %s | ||
| 11 | AND name IS NOT NULL AND name != '' | 12 | AND name IS NOT NULL AND name != '' |
| 12 | AND audio_url IS NOT NULL AND audio_url != '' | 13 | AND audio_url IS NOT NULL AND audio_url != '' |
| 13 | AND singer IS NOT NULL AND singer != '' | 14 | AND singer IS NOT NULL AND singer != '' |
| 14 | ORDER BY id | 15 | ORDER BY id |
| 15 | LIMIT %s OFFSET %s | 16 | LIMIT %s |
| 16 | """ | 17 | """ |
| 17 | 18 | ||
| 18 | _PLATFORM_QUERY = """ | 19 | _PLATFORM_QUERY = """ |
| ... | @@ -42,18 +43,22 @@ ORDER BY sar.song_id, | ... | @@ -42,18 +43,22 @@ ORDER BY sar.song_id, |
| 42 | """ | 43 | """ |
| 43 | 44 | ||
| 44 | 45 | ||
| 45 | def iter_hk_songs_batches(conn: pymysql.Connection, batch_size: int) -> Iterator[list[dict]]: | 46 | def iter_hk_songs_batches( |
| 46 | offset = 0 | 47 | conn: pymysql.Connection, |
| 48 | batch_size: int, | ||
| 49 | start_after_id: int = 0, | ||
| 50 | ) -> Iterator[list[dict]]: | ||
| 51 | last_id = start_after_id | ||
| 47 | with conn.cursor() as cur: | 52 | with conn.cursor() as cur: |
| 48 | while True: | 53 | while True: |
| 49 | cur.execute(_HK_SONGS_QUERY, (batch_size, offset)) | 54 | cur.execute(_HK_SONGS_QUERY, (last_id, batch_size)) |
| 50 | rows = cur.fetchall() | 55 | rows = cur.fetchall() |
| 51 | if not rows: | 56 | if not rows: |
| 52 | break | 57 | break |
| 53 | yield rows | 58 | yield rows |
| 59 | last_id = int(rows[-1]['id']) | ||
| 54 | if len(rows) < batch_size: | 60 | if len(rows) < batch_size: |
| 55 | break | 61 | break |
| 56 | offset += batch_size | ||
| 57 | 62 | ||
| 58 | 63 | ||
| 59 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | 64 | def fetch_platform_records(source_conn: pymysql.Connection, song_ids: list[int]) -> list[dict]: | ... | ... |
| 1 | import uuid | 1 | import uuid |
| 2 | import json | 2 | import json |
| 3 | import logging | 3 | import logging |
| 4 | from pathlib import Path | ||
| 4 | from tqdm import tqdm | 5 | from tqdm import tqdm |
| 5 | 6 | ||
| 6 | from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG | 7 | from .config import PLATFORM_QQ, PLATFORM_KUGOU, PLATFORM_NETEASE, BATCH_SIZE, OSS_CONFIG |
| ... | @@ -308,19 +309,59 @@ _PROCESSORS = { | ... | @@ -308,19 +309,59 @@ _PROCESSORS = { |
| 308 | } | 309 | } |
| 309 | 310 | ||
| 310 | 311 | ||
| 311 | def run(platforms: list[str], max_batches: int | None = None) -> None: | 312 | DEFAULT_STATE_FILE = Path('output/etl_to_crawler_state.json') |
| 313 | |||
| 314 | |||
| 315 | def _load_state(path: Path) -> dict: | ||
| 316 | if not path.exists(): | ||
| 317 | return {} | ||
| 318 | with path.open('r', encoding='utf-8') as f: | ||
| 319 | return json.load(f) | ||
| 320 | |||
| 321 | |||
| 322 | def _save_state(path: Path, state: dict) -> None: | ||
| 323 | path.parent.mkdir(parents=True, exist_ok=True) | ||
| 324 | with path.open('w', encoding='utf-8') as f: | ||
| 325 | json.dump(state, f, ensure_ascii=False, indent=2, sort_keys=True) | ||
| 326 | f.write('\n') | ||
| 327 | |||
| 328 | |||
| 329 | def _state_last_id(state: dict, key: str) -> int: | ||
| 330 | value = state.get(key, {}).get('last_hk_songs_id', 0) | ||
| 331 | return int(value or 0) | ||
| 332 | |||
| 333 | |||
| 334 | def run( | ||
| 335 | platforms: list[str], | ||
| 336 | max_batches: int | None = None, | ||
| 337 | resume: bool = False, | ||
| 338 | state_file: str | Path = DEFAULT_STATE_FILE, | ||
| 339 | state_key: str = 'all', | ||
| 340 | start_after_id: int | None = None, | ||
| 341 | reset_state: bool = False, | ||
| 342 | ) -> None: | ||
| 312 | hk_conn = get_hk_songs_conn() | 343 | hk_conn = get_hk_songs_conn() |
| 313 | src_conn = get_source_conn() | 344 | src_conn = get_source_conn() |
| 314 | spider_conn = get_spider_conn() | 345 | spider_conn = get_spider_conn() |
| 315 | pg_conn = get_pg_conn() | 346 | pg_conn = get_pg_conn() |
| 316 | bucket = get_oss_bucket() | 347 | bucket = get_oss_bucket() |
| 317 | base_url = OSS_CONFIG['base_url'] | 348 | base_url = OSS_CONFIG['base_url'] |
| 349 | state_path = Path(state_file) | ||
| 350 | state = _load_state(state_path) if resume and not reset_state else {} | ||
| 351 | resume_start_id = 0 | ||
| 352 | if resume: | ||
| 353 | resume_start_id = _state_last_id(state, state_key) | ||
| 354 | if start_after_id is not None: | ||
| 355 | resume_start_id = start_after_id | ||
| 318 | 356 | ||
| 319 | total_ok = total_err = 0 | 357 | total_ok = total_err = 0 |
| 320 | imported: list[dict] = [] | 358 | imported: list[dict] = [] |
| 321 | 359 | ||
| 322 | try: | 360 | try: |
| 323 | for i, batch in enumerate(tqdm(iter_hk_songs_batches(hk_conn, BATCH_SIZE), desc='batches')): | 361 | for i, batch in enumerate(tqdm( |
| 362 | iter_hk_songs_batches(hk_conn, BATCH_SIZE, start_after_id=resume_start_id), | ||
| 363 | desc='batches', | ||
| 364 | )): | ||
| 324 | if max_batches is not None and i >= max_batches: | 365 | if max_batches is not None and i >= max_batches: |
| 325 | break | 366 | break |
| 326 | song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')] | 367 | song_ids = [int(r['source_song_id']) for r in batch if r.get('source_song_id')] |
| ... | @@ -360,6 +401,9 @@ def run(platforms: list[str], max_batches: int | None = None) -> None: | ... | @@ -360,6 +401,9 @@ def run(platforms: list[str], max_batches: int | None = None) -> None: |
| 360 | hk_row.get('name'), pr['platform'], e) | 401 | hk_row.get('name'), pr['platform'], e) |
| 361 | total_err += 1 | 402 | total_err += 1 |
| 362 | pg_conn.commit() | 403 | pg_conn.commit() |
| 404 | if resume: | ||
| 405 | state.setdefault(state_key, {})['last_hk_songs_id'] = int(batch[-1]['id']) | ||
| 406 | _save_state(state_path, state) | ||
| 363 | 407 | ||
| 364 | finally: | 408 | finally: |
| 365 | hk_conn.close() | 409 | hk_conn.close() | ... | ... |
| ... | @@ -16,6 +16,14 @@ if __name__ == '__main__': | ... | @@ -16,6 +16,14 @@ if __name__ == '__main__': |
| 16 | help='要导入的平台(默认 all)') | 16 | help='要导入的平台(默认 all)') |
| 17 | parser.add_argument('--max-batches', type=int, default=None, | 17 | parser.add_argument('--max-batches', type=int, default=None, |
| 18 | help='最多处理多少批次(冒烟测试用)') | 18 | help='最多处理多少批次(冒烟测试用)') |
| 19 | parser.add_argument('--resume', action='store_true', | ||
| 20 | help='启用断点续传,按 hk_songs_test.id 从上次成功提交的批次后继续') | ||
| 21 | parser.add_argument('--state-file', default='output/etl_to_crawler_state.json', | ||
| 22 | help='断点状态文件路径') | ||
| 23 | parser.add_argument('--start-after-id', type=int, default=None, | ||
| 24 | help='手动指定从哪个 hk_songs_test.id 之后开始读取') | ||
| 25 | parser.add_argument('--reset-state', action='store_true', | ||
| 26 | help='忽略已有断点状态,从头或 --start-after-id 指定位置重新开始') | ||
| 19 | args = parser.parse_args() | 27 | args = parser.parse_args() |
| 20 | 28 | ||
| 21 | if args.platform == 'all': | 29 | if args.platform == 'all': |
| ... | @@ -24,4 +32,12 @@ if __name__ == '__main__': | ... | @@ -24,4 +32,12 @@ if __name__ == '__main__': |
| 24 | platforms = [PLATFORM_MAP[args.platform]] | 32 | platforms = [PLATFORM_MAP[args.platform]] |
| 25 | 33 | ||
| 26 | print(f"Starting ETL for platforms: {platforms}") | 34 | print(f"Starting ETL for platforms: {platforms}") |
| 27 | run(platforms, max_batches=args.max_batches) | 35 | run( |
| 36 | platforms, | ||
| 37 | max_batches=args.max_batches, | ||
| 38 | resume=args.resume, | ||
| 39 | state_file=args.state_file, | ||
| 40 | state_key=args.platform, | ||
| 41 | start_after_id=args.start_after_id, | ||
| 42 | reset_state=args.reset_state, | ||
| 43 | ) | ... | ... |
| 1 | from unittest.mock import MagicMock | 1 | from unittest.mock import MagicMock |
| 2 | 2 | ||
| 3 | from etl_to_crawler.reader import fetch_platform_records, select_primary_record | 3 | from etl_to_crawler.reader import ( |
| 4 | fetch_platform_records, | ||
| 5 | iter_hk_songs_batches, | ||
| 6 | select_primary_record, | ||
| 7 | ) | ||
| 4 | 8 | ||
| 5 | 9 | ||
| 6 | def _make_cursor(rows): | 10 | def _make_cursor(rows): |
| ... | @@ -122,3 +126,22 @@ def test_select_primary_record_uses_earliest_pub_time_when_priority_ties(): | ... | @@ -122,3 +126,22 @@ def test_select_primary_record_uses_earliest_pub_time_when_priority_ties(): |
| 122 | ] | 126 | ] |
| 123 | 127 | ||
| 124 | assert select_primary_record(rows)['record_id'] == 101 | 128 | assert select_primary_record(rows)['record_id'] == 101 |
| 129 | |||
| 130 | |||
| 131 | def test_iter_hk_songs_batches_uses_id_cursor_instead_of_offset(): | ||
| 132 | cur = _make_cursor([]) | ||
| 133 | cur.fetchall.side_effect = [ | ||
| 134 | [{'id': 10, 'source_song_id': 100}, {'id': 20, 'source_song_id': 200}], | ||
| 135 | [{'id': 25, 'source_song_id': 250}], | ||
| 136 | ] | ||
| 137 | conn = MagicMock() | ||
| 138 | conn.cursor.return_value = cur | ||
| 139 | |||
| 140 | batches = list(iter_hk_songs_batches(conn, batch_size=2, start_after_id=5)) | ||
| 141 | |||
| 142 | assert batches == [ | ||
| 143 | [{'id': 10, 'source_song_id': 100}, {'id': 20, 'source_song_id': 200}], | ||
| 144 | [{'id': 25, 'source_song_id': 250}], | ||
| 145 | ] | ||
| 146 | assert cur.execute.call_args_list[0][0][1] == (5, 2) | ||
| 147 | assert cur.execute.call_args_list[1][0][1] == (20, 2) | ... | ... |
| ... | @@ -48,7 +48,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -48,7 +48,7 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 48 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) | 48 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) |
| 49 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | 49 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) |
| 50 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) | 50 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) |
| 51 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size: [[{ | 51 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', lambda conn, batch_size, start_after_id=0: [[{ |
| 52 | 'source_song_id': 10, | 52 | 'source_song_id': 10, |
| 53 | 'name': '歌', | 53 | 'name': '歌', |
| 54 | 'audio_url': 'https://example.com/a.mp3', | 54 | 'audio_url': 'https://example.com/a.mp3', |
| ... | @@ -87,3 +87,32 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation | ... | @@ -87,3 +87,32 @@ def test_run_imports_all_platform_records_but_writes_one_primary_yinyan_relation |
| 87 | processors['2'].assert_called_once() | 87 | processors['2'].assert_called_once() |
| 88 | yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)]) | 88 | yinyan_writer.assert_called_once_with(pg_conn.cur, [(10, 200)]) |
| 89 | assert pg_conn.commits == 1 | 89 | assert pg_conn.commits == 1 |
| 90 | |||
| 91 | |||
| 92 | def test_run_resume_starts_after_saved_id_and_updates_state_after_commit(monkeypatch, tmp_path): | ||
| 93 | pg_conn = _PgConnection() | ||
| 94 | state_file = tmp_path / 'etl_state.json' | ||
| 95 | state_file.write_text('{"all": {"last_hk_songs_id": 40}}', encoding='utf-8') | ||
| 96 | seen_start_ids = [] | ||
| 97 | |||
| 98 | monkeypatch.setattr(runner, 'get_hk_songs_conn', lambda: _Connection()) | ||
| 99 | monkeypatch.setattr(runner, 'get_source_conn', lambda: _Connection()) | ||
| 100 | monkeypatch.setattr(runner, 'get_spider_conn', lambda: _Connection()) | ||
| 101 | monkeypatch.setattr(runner, 'get_pg_conn', lambda: pg_conn) | ||
| 102 | monkeypatch.setattr(runner, 'get_oss_bucket', lambda: object()) | ||
| 103 | |||
| 104 | def fake_batches(conn, batch_size, start_after_id=0): | ||
| 105 | seen_start_ids.append(start_after_id) | ||
| 106 | return iter([[ | ||
| 107 | {'id': 50, 'source_song_id': 10, 'name': '歌', 'audio_url': 'https://example.com/a.mp3', 'singer': '歌手'}, | ||
| 108 | {'id': 60, 'source_song_id': 20, 'name': '歌2', 'audio_url': 'https://example.com/b.mp3', 'singer': '歌手2'}, | ||
| 109 | ]]) | ||
| 110 | |||
| 111 | monkeypatch.setattr(runner, 'iter_hk_songs_batches', fake_batches) | ||
| 112 | monkeypatch.setattr(runner, 'fetch_platform_records', lambda conn, song_ids: []) | ||
| 113 | |||
| 114 | runner.run(['1', '2', '4'], resume=True, state_file=state_file, state_key='all') | ||
| 115 | |||
| 116 | assert seen_start_ids == [40] | ||
| 117 | assert pg_conn.commits == 1 | ||
| 118 | assert '"last_hk_songs_id": 60' in state_file.read_text(encoding='utf-8') | ... | ... |
-
Please register or sign in to post a comment