Commit dcddb309 dcddb309a8260585eb0584d92a7739fc32793938 by 沈秋雨

feat(etl): 支持断点续传功能提升数据导入可靠性

- run_etl.py中新增resume相关参数支持断点续传和手动起始位置
- reader.py中修改iter_hk_songs_batches函数,改用id游标替代offset分页
- runner.py新增断点状态文件管理功能,支持状态读写与断点继续
- run函数接受断点续传参数并根据状态文件恢复进度
- 处理每批次导入成功后保存断点状态,确保可中断恢复
- 新增单元测试覆盖断点续传逻辑,验证正确使用start_after_id参数
- 调整接口参数传递与兼容性,保持向后兼容性和灵活性
1 parent 0ab1bdc0
...@@ -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')
......