honor_backfill_oaid.py 10 KB

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