| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- """
- 定时任务:从 ODPS global_category 同步有效“实质”分类树到 MySQL。
- global_tree_element 已退出该流程。分类树直接以 stable_id /
- parent_stable_id 建立层级,本地表继续保留稳定的 MySQL id 供现有下游引用。
- """
- from __future__ import annotations
- import logging
- from datetime import datetime, timedelta
- from typing import Any
- from supply_infra.config import get_infra_settings
- from supply_infra.db.repositories.global_tree_category_repo import (
- GlobalTreeCategoryRepository,
- )
- from supply_infra.db.session import get_session
- from supply_infra.odps.client import get_odps_client
- logger = logging.getLogger(__name__)
- def _to_category_source_rows(
- categories: list[dict[str, Any]],
- ) -> list[dict[str, Any]]:
- """规范 stable_id 字段并按 stable_id 去重,保留最后一条。"""
- by_source_id: dict[int, dict[str, Any]] = {}
- for category in categories:
- stable_id = category.get("stable_id")
- name = category.get("name")
- if stable_id is None or name is None or not str(name).strip():
- logger.warning("Skip global category with missing stable_id/name: %s", category)
- continue
- parent_stable_id = category.get("parent_stable_id")
- source_id = int(stable_id)
- by_source_id[source_id] = {
- "source_id": source_id,
- "source_parent_id": (
- int(parent_stable_id)
- if parent_stable_id is not None and int(parent_stable_id) != 0
- else 0
- ),
- "name": str(name).strip(),
- "description": (
- str(category["description"])
- if category.get("description") is not None
- else None
- ),
- "level": (
- int(category["level"])
- if category.get("level") is not None
- else None
- ),
- }
- return sorted(
- by_source_id.values(),
- key=lambda row: (row.get("level") or 0, int(row["source_id"])),
- )
- def _allocate_local_ids(
- categories: list[dict[str, Any]],
- source_id_to_mysql_id: dict[int, int],
- next_id: int,
- ) -> tuple[dict[int, int], set[int]]:
- """为新 stable_id 分配本地 id;缺少父节点的行不会进入正式树。"""
- updated_map = dict(source_id_to_mysql_id)
- pending = [
- category
- for category in categories
- if int(category["source_id"]) not in updated_map
- ]
- failed: set[int] = set()
- while pending:
- next_pending: list[dict[str, Any]] = []
- progress = False
- for category in pending:
- source_id = int(category["source_id"])
- source_parent_id = int(category["source_parent_id"])
- if source_parent_id != 0 and source_parent_id not in updated_map:
- next_pending.append(category)
- continue
- updated_map[source_id] = next_id
- next_id += 1
- progress = True
- if not progress:
- failed.update(int(category["source_id"]) for category in next_pending)
- for category in next_pending:
- logger.warning(
- "Parent stable_id=%s not found for stable_id=%s, skip",
- category["source_parent_id"],
- category["source_id"],
- )
- break
- pending = next_pending
- return updated_map, failed
- def _to_mysql_rows(
- categories: list[dict[str, Any]],
- source_id_to_mysql_id: dict[int, int],
- failed_source_ids: set[int],
- ) -> list[dict[str, Any]]:
- rows: list[dict[str, Any]] = []
- for category in categories:
- source_id = int(category["source_id"])
- if source_id in failed_source_ids:
- continue
- source_parent_id = int(category["source_parent_id"])
- parent_id = (
- source_id_to_mysql_id.get(source_parent_id)
- if source_parent_id != 0
- else None
- )
- if source_parent_id != 0 and parent_id is None:
- failed_source_ids.add(source_id)
- continue
- rows.append(
- {
- "id": source_id_to_mysql_id[source_id],
- "name": category["name"],
- "description": category.get("description"),
- "level": category.get("level"),
- "parent_id": parent_id,
- "source_id": source_id,
- "source_parent_id": source_parent_id,
- "is_delete": 0,
- }
- )
- return rows
- def sync_global_tree_odps_to_mysql(partition_date: str | None = None) -> dict:
- """
- 从 global_category 拉取完整有效分类树并同步到 MySQL。
- Args:
- partition_date: 分区日期 (YYYYMMDD),默认昨天
- """
- if partition_date is None:
- partition_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
- logger.info(
- "Starting global_category ODPS → MySQL sync for partition: %s",
- partition_date,
- )
- raw_categories = get_odps_client().fetch_global_categories(partition_date)
- categories = _to_category_source_rows(raw_categories)
- if not categories:
- raise RuntimeError(
- f"global_category returned no active categories for dt={partition_date}"
- )
- with get_session() as session:
- repository = GlobalTreeCategoryRepository(session)
- existing_active_count = repository.count_active()
- minimum_retained = int(
- existing_active_count
- * get_infra_settings().global_category_min_retention_ratio
- )
- if existing_active_count and len(categories) < minimum_retained:
- raise RuntimeError(
- "global_category active row count dropped below safety threshold: "
- f"current={existing_active_count} incoming={len(categories)} "
- f"minimum={minimum_retained}"
- )
- existing_map = repository.get_source_id_map()
- source_id_to_mysql_id, failed_source_ids = _allocate_local_ids(
- categories,
- existing_map,
- repository.get_max_id() + 1,
- )
- rows = _to_mysql_rows(
- categories,
- source_id_to_mysql_id,
- failed_source_ids,
- )
- affected = repository.bulk_upsert(rows)
- active_source_ids = {int(category["source_id"]) for category in categories}
- retired = repository.mark_missing_deleted(active_source_ids)
- existing_source_ids = set(existing_map)
- inserted = sum(
- 1
- for row in rows
- if int(row["source_id"]) not in existing_source_ids
- )
- result = {
- "partition_date": partition_date,
- "source_type": "实质",
- "categories_fetched": len(raw_categories),
- "categories_valid": len(categories),
- "categories_inserted": inserted,
- "categories_existing_synced": len(rows) - inserted,
- "categories_failed": len(failed_source_ids),
- "categories_retired": retired,
- "rows_affected": affected,
- "global_tree_element_used": False,
- "synced_at": datetime.now().isoformat(),
- }
- logger.info("Global category sync completed: %s", result)
- return result
- if __name__ == "__main__":
- import sys
- from supply_infra.scheduler.cli_result import run_cli
- run_cli(
- lambda: sync_global_tree_odps_to_mysql(
- partition_date=sys.argv[1] if len(sys.argv) > 1 else None,
- ),
- label="sync_global_tree_odps_to_mysql",
- )
|