sync_global_tree_odps_to_mysql.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """
  2. 定时任务:从 ODPS global_category 同步有效“实质”分类树到 MySQL。
  3. global_tree_element 已退出该流程。分类树直接以 stable_id /
  4. parent_stable_id 建立层级,本地表继续保留稳定的 MySQL id 供现有下游引用。
  5. """
  6. from __future__ import annotations
  7. import logging
  8. from datetime import datetime, timedelta
  9. from typing import Any
  10. from supply_infra.config import get_infra_settings
  11. from supply_infra.db.repositories.global_tree_category_repo import (
  12. GlobalTreeCategoryRepository,
  13. )
  14. from supply_infra.db.session import get_session
  15. from supply_infra.odps.client import get_odps_client
  16. logger = logging.getLogger(__name__)
  17. def _to_category_source_rows(
  18. categories: list[dict[str, Any]],
  19. ) -> list[dict[str, Any]]:
  20. """规范 stable_id 字段并按 stable_id 去重,保留最后一条。"""
  21. by_source_id: dict[int, dict[str, Any]] = {}
  22. for category in categories:
  23. stable_id = category.get("stable_id")
  24. name = category.get("name")
  25. if stable_id is None or name is None or not str(name).strip():
  26. logger.warning("Skip global category with missing stable_id/name: %s", category)
  27. continue
  28. parent_stable_id = category.get("parent_stable_id")
  29. source_id = int(stable_id)
  30. by_source_id[source_id] = {
  31. "source_id": source_id,
  32. "source_parent_id": (
  33. int(parent_stable_id)
  34. if parent_stable_id is not None and int(parent_stable_id) != 0
  35. else 0
  36. ),
  37. "name": str(name).strip(),
  38. "description": (
  39. str(category["description"])
  40. if category.get("description") is not None
  41. else None
  42. ),
  43. "level": (
  44. int(category["level"])
  45. if category.get("level") is not None
  46. else None
  47. ),
  48. }
  49. return sorted(
  50. by_source_id.values(),
  51. key=lambda row: (row.get("level") or 0, int(row["source_id"])),
  52. )
  53. def _allocate_local_ids(
  54. categories: list[dict[str, Any]],
  55. source_id_to_mysql_id: dict[int, int],
  56. next_id: int,
  57. ) -> tuple[dict[int, int], set[int]]:
  58. """为新 stable_id 分配本地 id;缺少父节点的行不会进入正式树。"""
  59. updated_map = dict(source_id_to_mysql_id)
  60. pending = [
  61. category
  62. for category in categories
  63. if int(category["source_id"]) not in updated_map
  64. ]
  65. failed: set[int] = set()
  66. while pending:
  67. next_pending: list[dict[str, Any]] = []
  68. progress = False
  69. for category in pending:
  70. source_id = int(category["source_id"])
  71. source_parent_id = int(category["source_parent_id"])
  72. if source_parent_id != 0 and source_parent_id not in updated_map:
  73. next_pending.append(category)
  74. continue
  75. updated_map[source_id] = next_id
  76. next_id += 1
  77. progress = True
  78. if not progress:
  79. failed.update(int(category["source_id"]) for category in next_pending)
  80. for category in next_pending:
  81. logger.warning(
  82. "Parent stable_id=%s not found for stable_id=%s, skip",
  83. category["source_parent_id"],
  84. category["source_id"],
  85. )
  86. break
  87. pending = next_pending
  88. return updated_map, failed
  89. def _to_mysql_rows(
  90. categories: list[dict[str, Any]],
  91. source_id_to_mysql_id: dict[int, int],
  92. failed_source_ids: set[int],
  93. ) -> list[dict[str, Any]]:
  94. rows: list[dict[str, Any]] = []
  95. for category in categories:
  96. source_id = int(category["source_id"])
  97. if source_id in failed_source_ids:
  98. continue
  99. source_parent_id = int(category["source_parent_id"])
  100. parent_id = (
  101. source_id_to_mysql_id.get(source_parent_id)
  102. if source_parent_id != 0
  103. else None
  104. )
  105. if source_parent_id != 0 and parent_id is None:
  106. failed_source_ids.add(source_id)
  107. continue
  108. rows.append(
  109. {
  110. "id": source_id_to_mysql_id[source_id],
  111. "name": category["name"],
  112. "description": category.get("description"),
  113. "level": category.get("level"),
  114. "parent_id": parent_id,
  115. "source_id": source_id,
  116. "source_parent_id": source_parent_id,
  117. "is_delete": 0,
  118. }
  119. )
  120. return rows
  121. def sync_global_tree_odps_to_mysql(partition_date: str | None = None) -> dict:
  122. """
  123. 从 global_category 拉取完整有效分类树并同步到 MySQL。
  124. Args:
  125. partition_date: 分区日期 (YYYYMMDD),默认昨天
  126. """
  127. if partition_date is None:
  128. partition_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  129. logger.info(
  130. "Starting global_category ODPS → MySQL sync for partition: %s",
  131. partition_date,
  132. )
  133. raw_categories = get_odps_client().fetch_global_categories(partition_date)
  134. categories = _to_category_source_rows(raw_categories)
  135. if not categories:
  136. raise RuntimeError(
  137. f"global_category returned no active categories for dt={partition_date}"
  138. )
  139. with get_session() as session:
  140. repository = GlobalTreeCategoryRepository(session)
  141. existing_active_count = repository.count_active()
  142. minimum_retained = int(
  143. existing_active_count
  144. * get_infra_settings().global_category_min_retention_ratio
  145. )
  146. if existing_active_count and len(categories) < minimum_retained:
  147. raise RuntimeError(
  148. "global_category active row count dropped below safety threshold: "
  149. f"current={existing_active_count} incoming={len(categories)} "
  150. f"minimum={minimum_retained}"
  151. )
  152. existing_map = repository.get_source_id_map()
  153. source_id_to_mysql_id, failed_source_ids = _allocate_local_ids(
  154. categories,
  155. existing_map,
  156. repository.get_max_id() + 1,
  157. )
  158. rows = _to_mysql_rows(
  159. categories,
  160. source_id_to_mysql_id,
  161. failed_source_ids,
  162. )
  163. affected = repository.bulk_upsert(rows)
  164. active_source_ids = {int(category["source_id"]) for category in categories}
  165. retired = repository.mark_missing_deleted(active_source_ids)
  166. existing_source_ids = set(existing_map)
  167. inserted = sum(
  168. 1
  169. for row in rows
  170. if int(row["source_id"]) not in existing_source_ids
  171. )
  172. result = {
  173. "partition_date": partition_date,
  174. "source_type": "实质",
  175. "categories_fetched": len(raw_categories),
  176. "categories_valid": len(categories),
  177. "categories_inserted": inserted,
  178. "categories_existing_synced": len(rows) - inserted,
  179. "categories_failed": len(failed_source_ids),
  180. "categories_retired": retired,
  181. "rows_affected": affected,
  182. "global_tree_element_used": False,
  183. "synced_at": datetime.now().isoformat(),
  184. }
  185. logger.info("Global category sync completed: %s", result)
  186. return result
  187. if __name__ == "__main__":
  188. import sys
  189. from supply_infra.scheduler.cli_result import run_cli
  190. run_cli(
  191. lambda: sync_global_tree_odps_to_mysql(
  192. partition_date=sys.argv[1] if len(sys.argv) > 1 else None,
  193. ),
  194. label="sync_global_tree_odps_to_mysql",
  195. )