honor_backfill_oaid.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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]) -> 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_trace_id IN ({})
  165. """
  166. with conn.cursor() as cur:
  167. for i in range(0, len(trace_ids), chunk_size):
  168. chunk = trace_ids[i:i + chunk_size]
  169. placeholders = ",".join(["%s"] * len(chunk))
  170. cur.execute(sql.format(placeholders), chunk)
  171. for qk, media_trace_id, media_params in cur.fetchall():
  172. rows[media_trace_id] = {
  173. "qk": qk,
  174. "trace_id": media_trace_id,
  175. "media_params": json.loads(media_params) if media_params else {},
  176. }
  177. return rows
  178. def update_mysql(conn, trace_id: str, params: dict, dry_run: bool) -> bool:
  179. sql = """
  180. UPDATE honor_ad_bid_events
  181. SET media_params = %s
  182. WHERE media_trace_id = %s
  183. """
  184. if dry_run:
  185. return True
  186. with conn.cursor() as cur:
  187. cur.execute(sql, (json.dumps(params, ensure_ascii=False), trace_id))
  188. return True
  189. def patch_redis_payload(payload: str, oaid: str, oaid_md5: str) -> Tuple[str, bool]:
  190. data = json.loads(payload)
  191. media_params = data.get("mediaParams") or {}
  192. changed = False
  193. if media_params.get("oaid") != oaid:
  194. media_params["oaid"] = oaid
  195. changed = True
  196. if media_params.get("oaidMd5") != oaid_md5:
  197. media_params["oaidMd5"] = oaid_md5
  198. changed = True
  199. data["mediaParams"] = media_params
  200. return json.dumps(data, ensure_ascii=False), changed
  201. def main() -> int:
  202. args = parse_args()
  203. log_files = expand_logs(args.log)
  204. if not log_files:
  205. print("no log files matched")
  206. return 1
  207. print(
  208. f"[start] logs={len(log_files)} dry_run={args.dry_run} limit={args.limit or 'all'} progress_every={args.progress_every}"
  209. )
  210. for path in log_files:
  211. print(f"[start] log={path}")
  212. mappings = scan_logs(log_files, args.progress_every)
  213. if not mappings:
  214. print("no honor traceId/oaid mappings found")
  215. return 1
  216. items = list(mappings.items())
  217. if args.limit > 0:
  218. items = items[:args.limit]
  219. trace_ids = [trace_id for trace_id, _ in items]
  220. print(f"[scan] extracted_mappings={len(items)}")
  221. conn = pymysql.connect(
  222. host=args.mysql_host,
  223. port=args.mysql_port,
  224. user=args.mysql_user,
  225. password=args.mysql_password,
  226. database=args.mysql_db,
  227. charset="utf8mb4",
  228. autocommit=False,
  229. )
  230. redis_client = RespRedisClient(
  231. host=args.redis_host,
  232. port=args.redis_port,
  233. user=args.redis_user,
  234. password=args.redis_password,
  235. db=args.redis_db,
  236. )
  237. try:
  238. db_rows = fetch_rows(conn, trace_ids)
  239. stats = {
  240. "log_mappings": len(items),
  241. "db_found": 0,
  242. "mysql_updated": 0,
  243. "redis_keys_checked": 0,
  244. "redis_keys_updated": 0,
  245. "already_ok": 0,
  246. "db_missing": 0,
  247. "redis_missing": 0,
  248. }
  249. for index, (trace_id, entry) in enumerate(items, start=1):
  250. if args.progress_every > 0 and index % args.progress_every == 0:
  251. print(
  252. f"[apply] processed={index}/{len(items)} mysql_updated={stats['mysql_updated']} "
  253. f"redis_updated={stats['redis_keys_updated']} db_missing={stats['db_missing']}"
  254. )
  255. row = db_rows.get(trace_id)
  256. if not row:
  257. stats["db_missing"] += 1
  258. continue
  259. stats["db_found"] += 1
  260. oaid_md5 = md5_hex(entry.oaid)
  261. media_params = row["media_params"] or {}
  262. mysql_changed = False
  263. if media_params.get("oaid") != entry.oaid:
  264. media_params["oaid"] = entry.oaid
  265. mysql_changed = True
  266. if media_params.get("oaidMd5") != oaid_md5:
  267. media_params["oaidMd5"] = oaid_md5
  268. mysql_changed = True
  269. if mysql_changed:
  270. update_mysql(conn, trace_id, media_params, args.dry_run)
  271. stats["mysql_updated"] += 1
  272. else:
  273. stats["already_ok"] += 1
  274. redis_keys = [
  275. f"{args.redis_prefix}bid:{row['qk']}",
  276. f"{args.redis_prefix}media:honor:{trace_id}",
  277. ]
  278. for key in redis_keys:
  279. stats["redis_keys_checked"] += 1
  280. payload = redis_client.get(key)
  281. if payload is None:
  282. stats["redis_missing"] += 1
  283. continue
  284. ttl_ms = redis_client.pttl(key)
  285. if ttl_ms <= 0:
  286. stats["redis_missing"] += 1
  287. continue
  288. new_payload, changed = patch_redis_payload(payload, entry.oaid, oaid_md5)
  289. if changed:
  290. if not args.dry_run:
  291. redis_client.set_px(key, new_payload, ttl_ms)
  292. stats["redis_keys_updated"] += 1
  293. if args.dry_run:
  294. conn.rollback()
  295. else:
  296. conn.commit()
  297. print(f"[done] processed={len(items)}")
  298. print(json.dumps(stats, ensure_ascii=False, indent=2))
  299. return 0
  300. finally:
  301. redis_client.close()
  302. conn.close()
  303. if __name__ == "__main__":
  304. raise SystemExit(main())