honor_backfill_oaid.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. #!/usr/bin/env python3
  2. import argparse
  3. import glob
  4. import hashlib
  5. import json
  6. import re
  7. import socket
  8. from pathlib import Path
  9. from typing import Dict, Iterable, List, Optional, Tuple
  10. import pymysql
  11. TRACE_ID_RE = re.compile(r"traceId=([^\s]+)")
  12. TRACK_ID_RE = re.compile(r"trackId=([^,\s}]+)")
  13. OAID_RE = re.compile(r"oaid=([^,\s}]+)")
  14. class LogEntry:
  15. def __init__(self, trace_id: str, oaid: str):
  16. self.trace_id = trace_id
  17. self.oaid = oaid
  18. def md5_hex(value: str) -> str:
  19. return hashlib.md5(value.encode("utf-8")).hexdigest()
  20. def parse_args() -> argparse.Namespace:
  21. parser = argparse.ArgumentParser(
  22. description="Backfill honor oaid/oaidMd5 into TiDB cold data and Redis hot data from logs."
  23. )
  24. parser.add_argument(
  25. "--log",
  26. action="append",
  27. required=True,
  28. help="Log file path or glob. Repeatable, e.g. --log 'logs/tencent-baidu-tracking.2026-07-2[56].log'",
  29. )
  30. parser.add_argument("--mysql-host", required=True)
  31. parser.add_argument("--mysql-port", type=int, default=3390)
  32. parser.add_argument("--mysql-user", required=True)
  33. parser.add_argument("--mysql-password", required=True)
  34. parser.add_argument("--mysql-db", default="adx_tencent")
  35. parser.add_argument("--redis-host", required=True)
  36. parser.add_argument("--redis-port", type=int, default=6379)
  37. parser.add_argument("--redis-user", default="")
  38. parser.add_argument("--redis-password", required=True)
  39. parser.add_argument("--redis-db", type=int, default=5)
  40. parser.add_argument("--redis-prefix", default="adx:honor:")
  41. parser.add_argument("--limit", type=int, default=0, help="Only process first N extracted mappings")
  42. parser.add_argument(
  43. "--progress-every",
  44. type=int,
  45. default=100000,
  46. help="Print progress every N scanned log lines / processed mappings",
  47. )
  48. parser.add_argument("--dry-run", action="store_true")
  49. return parser.parse_args()
  50. def expand_logs(patterns: Iterable[str]) -> List[Path]:
  51. files: List[Path] = []
  52. for pattern in patterns:
  53. matched = sorted(Path(p) for p in glob.glob(pattern))
  54. if matched:
  55. files.extend(matched)
  56. else:
  57. path = Path(pattern)
  58. if path.exists():
  59. files.append(path)
  60. deduped: List[Path] = []
  61. seen = set()
  62. for path in files:
  63. key = str(path.resolve())
  64. if key not in seen:
  65. seen.add(key)
  66. deduped.append(path)
  67. return deduped
  68. def scan_logs(paths: Iterable[Path], progress_every: int) -> Dict[str, LogEntry]:
  69. result: Dict[str, LogEntry] = {}
  70. for path in paths:
  71. print(f"[scan] start file={path}")
  72. line_count = 0
  73. match_count = 0
  74. with path.open("r", encoding="utf-8", errors="ignore") as fh:
  75. for line in fh:
  76. line_count += 1
  77. if progress_every > 0 and line_count % progress_every == 0:
  78. print(
  79. f"[scan] file={path} lines={line_count} mappings={len(result)} fileMatches={match_count}"
  80. )
  81. if "[荣耀][曝光][入参]" not in line and "[荣耀][点击][入参]" not in line:
  82. continue
  83. trace_match = TRACE_ID_RE.search(line)
  84. oaid_match = OAID_RE.search(line)
  85. if not trace_match or not oaid_match:
  86. track_match = TRACK_ID_RE.search(line)
  87. if not track_match or not oaid_match:
  88. continue
  89. trace_id = track_match.group(1)
  90. else:
  91. trace_id = trace_match.group(1)
  92. oaid = oaid_match.group(1).strip()
  93. if not oaid:
  94. continue
  95. result[trace_id] = LogEntry(trace_id=trace_id, oaid=oaid)
  96. match_count += 1
  97. print(f"[scan] done file={path} lines={line_count} mappings={len(result)} fileMatches={match_count}")
  98. return result
  99. class RespRedisClient:
  100. def __init__(self, host: str, port: int, user: str, password: str, db: int):
  101. self.sock = socket.create_connection((host, port), timeout=10)
  102. self.file = self.sock.makefile("rb")
  103. self._auth(user, password)
  104. self._select(db)
  105. def close(self) -> None:
  106. try:
  107. self.file.close()
  108. finally:
  109. self.sock.close()
  110. def _write_command(self, *parts: str) -> None:
  111. payload = [f"*{len(parts)}\r\n".encode("utf-8")]
  112. for part in parts:
  113. data = part.encode("utf-8")
  114. payload.append(f"${len(data)}\r\n".encode("utf-8"))
  115. payload.append(data + b"\r\n")
  116. self.sock.sendall(b"".join(payload))
  117. def _read_response(self):
  118. prefix = self.file.read(1)
  119. if not prefix:
  120. raise RuntimeError("redis connection closed")
  121. if prefix == b"+":
  122. return self.file.readline().decode("utf-8").rstrip("\r\n")
  123. if prefix == b"-":
  124. raise RuntimeError(self.file.readline().decode("utf-8").rstrip("\r\n"))
  125. if prefix == b":":
  126. return int(self.file.readline().decode("utf-8").rstrip("\r\n"))
  127. if prefix == b"$":
  128. length = int(self.file.readline().decode("utf-8").rstrip("\r\n"))
  129. if length == -1:
  130. return None
  131. data = self.file.read(length)
  132. self.file.read(2)
  133. return data.decode("utf-8")
  134. if prefix == b"*":
  135. length = int(self.file.readline().decode("utf-8").rstrip("\r\n"))
  136. if length == -1:
  137. return None
  138. return [self._read_response() for _ in range(length)]
  139. raise RuntimeError(f"unsupported redis response prefix: {prefix!r}")
  140. def _auth(self, user: str, password: str) -> None:
  141. if user:
  142. self._write_command("AUTH", user, password)
  143. else:
  144. self._write_command("AUTH", password)
  145. self._read_response()
  146. def _select(self, db: int) -> None:
  147. self._write_command("SELECT", str(db))
  148. self._read_response()
  149. def get(self, key: str) -> Optional[str]:
  150. self._write_command("GET", key)
  151. return self._read_response()
  152. def pttl(self, key: str) -> int:
  153. self._write_command("PTTL", key)
  154. return int(self._read_response())
  155. def set_px(self, key: str, value: str, px: int) -> None:
  156. self._write_command("SET", key, value, "PX", str(px))
  157. self._read_response()
  158. def fetch_rows(conn, trace_ids: List[str], progress_every: int) -> Dict[str, dict]:
  159. rows: Dict[str, dict] = {}
  160. chunk_size = 500
  161. sql = """
  162. SELECT qk, media_trace_id, media_params
  163. FROM honor_ad_bid_events
  164. WHERE media = 'honor' AND media_trace_id IN ({})
  165. """
  166. total_chunks = (len(trace_ids) + chunk_size - 1) // chunk_size
  167. with conn.cursor() as cur:
  168. for i in range(0, len(trace_ids), chunk_size):
  169. chunk = trace_ids[i:i + chunk_size]
  170. placeholders = ",".join(["%s"] * len(chunk))
  171. chunk_no = i // chunk_size + 1
  172. if chunk_no == 1 or chunk_no == total_chunks or (progress_every > 0 and chunk_no % max(progress_every // chunk_size, 1) == 0):
  173. print(
  174. f"[db] fetching chunk={chunk_no}/{total_chunks} chunkSize={len(chunk)} rowsLoaded={len(rows)}"
  175. )
  176. cur.execute(sql.format(placeholders), chunk)
  177. for qk, media_trace_id, media_params in cur.fetchall():
  178. rows[media_trace_id] = {
  179. "qk": qk,
  180. "trace_id": media_trace_id,
  181. "media_params": json.loads(media_params) if media_params else {},
  182. }
  183. print(f"[db] fetched rows={len(rows)}")
  184. return rows
  185. def update_mysql(conn, trace_id: str, params: dict, dry_run: bool) -> bool:
  186. sql = """
  187. UPDATE honor_ad_bid_events
  188. SET media_params = %s
  189. WHERE media = 'honor' AND media_trace_id = %s
  190. """
  191. if dry_run:
  192. return True
  193. with conn.cursor() as cur:
  194. cur.execute(sql, (json.dumps(params, ensure_ascii=False), trace_id))
  195. return True
  196. def patch_redis_payload(payload: str, oaid: str, oaid_md5: str) -> Tuple[str, bool]:
  197. data = json.loads(payload)
  198. media_params = data.get("mediaParams") or {}
  199. changed = False
  200. if media_params.get("oaid") != oaid:
  201. media_params["oaid"] = oaid
  202. changed = True
  203. if media_params.get("oaidMd5") != oaid_md5:
  204. media_params["oaidMd5"] = oaid_md5
  205. changed = True
  206. data["mediaParams"] = media_params
  207. return json.dumps(data, ensure_ascii=False), changed
  208. def main() -> int:
  209. args = parse_args()
  210. log_files = expand_logs(args.log)
  211. if not log_files:
  212. print("no log files matched")
  213. return 1
  214. print(
  215. f"[start] logs={len(log_files)} dry_run={args.dry_run} limit={args.limit or 'all'} progress_every={args.progress_every}"
  216. )
  217. for path in log_files:
  218. print(f"[start] log={path}")
  219. mappings = scan_logs(log_files, args.progress_every)
  220. if not mappings:
  221. print("no honor traceId/oaid mappings found")
  222. return 1
  223. items = list(mappings.items())
  224. if args.limit > 0:
  225. items = items[:args.limit]
  226. trace_ids = [trace_id for trace_id, _ in items]
  227. print(f"[scan] extracted_mappings={len(items)}")
  228. conn = pymysql.connect(
  229. host=args.mysql_host,
  230. port=args.mysql_port,
  231. user=args.mysql_user,
  232. password=args.mysql_password,
  233. database=args.mysql_db,
  234. charset="utf8mb4",
  235. autocommit=False,
  236. )
  237. redis_client = RespRedisClient(
  238. host=args.redis_host,
  239. port=args.redis_port,
  240. user=args.redis_user,
  241. password=args.redis_password,
  242. db=args.redis_db,
  243. )
  244. try:
  245. db_rows = fetch_rows(conn, trace_ids, args.progress_every)
  246. stats = {
  247. "log_mappings": len(items),
  248. "db_found": 0,
  249. "mysql_updated": 0,
  250. "redis_keys_checked": 0,
  251. "redis_keys_updated": 0,
  252. "already_ok": 0,
  253. "db_missing": 0,
  254. "redis_missing": 0,
  255. }
  256. for index, (trace_id, entry) in enumerate(items, start=1):
  257. if args.progress_every > 0 and index % args.progress_every == 0:
  258. print(
  259. f"[apply] processed={index}/{len(items)} mysql_updated={stats['mysql_updated']} "
  260. f"redis_updated={stats['redis_keys_updated']} db_missing={stats['db_missing']}"
  261. )
  262. row = db_rows.get(trace_id)
  263. if not row:
  264. stats["db_missing"] += 1
  265. continue
  266. stats["db_found"] += 1
  267. oaid_md5 = md5_hex(entry.oaid)
  268. media_params = row["media_params"] or {}
  269. mysql_changed = False
  270. if media_params.get("oaid") != entry.oaid:
  271. media_params["oaid"] = entry.oaid
  272. mysql_changed = True
  273. if media_params.get("oaidMd5") != oaid_md5:
  274. media_params["oaidMd5"] = oaid_md5
  275. mysql_changed = True
  276. if mysql_changed:
  277. update_mysql(conn, trace_id, media_params, args.dry_run)
  278. stats["mysql_updated"] += 1
  279. else:
  280. stats["already_ok"] += 1
  281. redis_keys = [
  282. f"{args.redis_prefix}bid:{row['qk']}",
  283. f"{args.redis_prefix}media:honor:{trace_id}",
  284. ]
  285. for key in redis_keys:
  286. stats["redis_keys_checked"] += 1
  287. payload = redis_client.get(key)
  288. if payload is None:
  289. stats["redis_missing"] += 1
  290. continue
  291. ttl_ms = redis_client.pttl(key)
  292. if ttl_ms <= 0:
  293. stats["redis_missing"] += 1
  294. continue
  295. new_payload, changed = patch_redis_payload(payload, entry.oaid, oaid_md5)
  296. if changed:
  297. if not args.dry_run:
  298. redis_client.set_px(key, new_payload, ttl_ms)
  299. stats["redis_keys_updated"] += 1
  300. if args.dry_run:
  301. conn.rollback()
  302. else:
  303. conn.commit()
  304. print(f"[done] processed={len(items)}")
  305. print(json.dumps(stats, ensure_ascii=False, indent=2))
  306. return 0
  307. finally:
  308. redis_client.close()
  309. conn.close()
  310. if __name__ == "__main__":
  311. raise SystemExit(main())