|
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|
|
import argparse
|
|
import argparse
|
|
|
import json
|
|
import json
|
|
|
import sys
|
|
import sys
|
|
|
-from datetime import datetime, timedelta
|
|
|
|
|
|
|
+import time
|
|
|
|
|
+from datetime import date, datetime, timedelta
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
-from typing import Any
|
|
|
|
|
|
|
+from typing import Any, Callable
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
@@ -24,15 +25,20 @@ from app.hot_content.types import FlowConfig
|
|
|
from app.hot_content.wxindex_words import run_wxindex_words_daily_job
|
|
from app.hot_content.wxindex_words import run_wxindex_words_daily_job
|
|
|
from app.hot_content.wxindex_heat_pattern import run_wxindex_heat_pattern_daily_job
|
|
from app.hot_content.wxindex_heat_pattern import run_wxindex_heat_pattern_daily_job
|
|
|
from app.festival_demand.config import load_festival_demand_config
|
|
from app.festival_demand.config import load_festival_demand_config
|
|
|
|
|
+from app.festival_demand.repository import FestivalDemandRepository
|
|
|
from app.festival_demand.service import run_festival_demand_daily_job, print_festival_demand_summary
|
|
from app.festival_demand.service import run_festival_demand_daily_job, print_festival_demand_summary
|
|
|
from app.festival_demand.types import FestivalDemandConfig
|
|
from app.festival_demand.types import FestivalDemandConfig
|
|
|
from app.gap_script_demand.config import load_gap_script_demand_config
|
|
from app.gap_script_demand.config import load_gap_script_demand_config
|
|
|
|
|
+from app.gap_script_demand.repository import GapScriptDemandRepository
|
|
|
from app.gap_script_demand.service import (
|
|
from app.gap_script_demand.service import (
|
|
|
print_gap_script_demand_summary,
|
|
print_gap_script_demand_summary,
|
|
|
run_gap_script_demand_daily_job,
|
|
run_gap_script_demand_daily_job,
|
|
|
)
|
|
)
|
|
|
from app.gap_script_demand.types import GapScriptDemandConfig
|
|
from app.gap_script_demand.types import GapScriptDemandConfig
|
|
|
|
|
|
|
|
|
|
+# 当天失败后自动重试间隔(秒)
|
|
|
|
|
+SAME_DAY_RETRY_INTERVAL_SECONDS = 10 * 60
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def _import_blocking_scheduler() -> Any:
|
|
def _import_blocking_scheduler() -> Any:
|
|
|
try:
|
|
try:
|
|
@@ -124,34 +130,151 @@ def run_wxindex_heat_pattern_job(config: FlowConfig) -> None:
|
|
|
repository.close()
|
|
repository.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
-def run_festival_demand_job(config: FestivalDemandConfig) -> None:
|
|
|
|
|
- try:
|
|
|
|
|
- summary = run_festival_demand_daily_job(config)
|
|
|
|
|
- print_festival_demand_summary(summary)
|
|
|
|
|
- print(
|
|
|
|
|
- json.dumps(
|
|
|
|
|
- {"job": "festival_demand", "summary": summary},
|
|
|
|
|
- ensure_ascii=False,
|
|
|
|
|
- indent=2,
|
|
|
|
|
|
|
+def _today_shanghai() -> date:
|
|
|
|
|
+ return datetime.now(SHANGHAI_TZ).date()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _is_write_success(summary: dict[str, Any]) -> bool:
|
|
|
|
|
+ """MySQL 已写入,或分区已有数据(视为当天写入目标已达成)。"""
|
|
|
|
|
+ skip_reason = summary.get("skip_reason")
|
|
|
|
|
+ if skip_reason == "partition_data_exists":
|
|
|
|
|
+ return True
|
|
|
|
|
+ mysql_save = summary.get("mysql_save") or {}
|
|
|
|
|
+ if mysql_save.get("skipped") and mysql_save.get("skip_reason") == "partition_data_exists":
|
|
|
|
|
+ return True
|
|
|
|
|
+ if mysql_save and not mysql_save.get("skipped"):
|
|
|
|
|
+ return True
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _festival_demand_needs_retry(summary: dict[str, Any]) -> bool:
|
|
|
|
|
+ """节日需求:异常外的可恢复失败(如源数据未就绪)需要重试。"""
|
|
|
|
|
+ if _is_write_success(summary):
|
|
|
|
|
+ return False
|
|
|
|
|
+ skip_reason = summary.get("skip_reason")
|
|
|
|
|
+ # 当天无活跃节日 / 测试跳过 / 流程跑完但无需落库 → 视为当天完成,不再重试
|
|
|
|
|
+ if skip_reason in {"no_active_festivals", "skip_odps"}:
|
|
|
|
|
+ return False
|
|
|
|
|
+ if skip_reason == "no_demand_names":
|
|
|
|
|
+ return True
|
|
|
|
|
+ # 匹配完成但无生成需求(无需写入)
|
|
|
|
|
+ if not skip_reason and not (summary.get("mysql_save") or {}):
|
|
|
|
|
+ return False
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _gap_script_demand_needs_retry(summary: dict[str, Any]) -> bool:
|
|
|
|
|
+ """脚本主驱:源数据未就绪需重试;无匹配项则当天完成。"""
|
|
|
|
|
+ if _is_write_success(summary):
|
|
|
|
|
+ return False
|
|
|
|
|
+ skip_reason = summary.get("skip_reason")
|
|
|
|
|
+ if skip_reason == "no_matched_demands":
|
|
|
|
|
+ return False
|
|
|
|
|
+ if skip_reason == "no_demand_names":
|
|
|
|
|
+ return True
|
|
|
|
|
+ if not skip_reason and not (summary.get("mysql_save") or {}):
|
|
|
|
|
+ return False
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _run_with_same_day_retry(
|
|
|
|
|
+ *,
|
|
|
|
|
+ job_name: str,
|
|
|
|
|
+ query_date: date,
|
|
|
|
|
+ run_once_fn: Callable[[], dict[str, Any]],
|
|
|
|
|
+ needs_retry: Callable[[dict[str, Any]], bool],
|
|
|
|
|
+ print_summary: Callable[[dict[str, Any]], None],
|
|
|
|
|
+ interval_seconds: int = SAME_DAY_RETRY_INTERVAL_SECONDS,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """当天失败后每隔 interval_seconds 重试,直到写入成功或跨日停止。"""
|
|
|
|
|
+ attempt = 0
|
|
|
|
|
+ while True:
|
|
|
|
|
+ attempt += 1
|
|
|
|
|
+ try:
|
|
|
|
|
+ summary = run_once_fn()
|
|
|
|
|
+ print_summary(summary)
|
|
|
|
|
+ print(
|
|
|
|
|
+ json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "job": job_name,
|
|
|
|
|
+ "attempt": attempt,
|
|
|
|
|
+ "query_date": query_date.isoformat(),
|
|
|
|
|
+ "summary": summary,
|
|
|
|
|
+ },
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ indent=2,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ if not needs_retry(summary):
|
|
|
|
|
+ if _is_write_success(summary):
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: write success on attempt={attempt} "
|
|
|
|
|
+ f"query_date={query_date.isoformat()}",
|
|
|
|
|
+ flush=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: finished without retry "
|
|
|
|
|
+ f"attempt={attempt} skip_reason={summary.get('skip_reason')!r} "
|
|
|
|
|
+ f"query_date={query_date.isoformat()}",
|
|
|
|
|
+ flush=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ retry_reason = summary.get("skip_reason") or "incomplete"
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: not ready ({retry_reason}), "
|
|
|
|
|
+ f"retry in {interval_seconds}s "
|
|
|
|
|
+ f"(attempt={attempt}, query_date={query_date.isoformat()})",
|
|
|
|
|
+ flush=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ print(f"{job_name} failed: {exc}", file=sys.stderr)
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: retry in {interval_seconds}s "
|
|
|
|
|
+ f"(attempt={attempt}, query_date={query_date.isoformat()})",
|
|
|
|
|
+ flush=True,
|
|
|
)
|
|
)
|
|
|
- )
|
|
|
|
|
- except Exception as exc:
|
|
|
|
|
- print(f"festival demand failed: {exc}", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
+ if _today_shanghai() != query_date:
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: stop retry, day changed "
|
|
|
|
|
+ f"(query_date={query_date.isoformat()})",
|
|
|
|
|
+ flush=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
|
|
|
-def run_gap_script_demand_job(config: GapScriptDemandConfig) -> None:
|
|
|
|
|
- try:
|
|
|
|
|
- summary = run_gap_script_demand_daily_job(config)
|
|
|
|
|
- print_gap_script_demand_summary(summary)
|
|
|
|
|
- print(
|
|
|
|
|
- json.dumps(
|
|
|
|
|
- {"job": "gap_script_demand", "summary": summary},
|
|
|
|
|
- ensure_ascii=False,
|
|
|
|
|
- indent=2,
|
|
|
|
|
|
|
+ time.sleep(max(interval_seconds, 1))
|
|
|
|
|
+
|
|
|
|
|
+ if _today_shanghai() != query_date:
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: stop retry after sleep, day changed "
|
|
|
|
|
+ f"(query_date={query_date.isoformat()})",
|
|
|
|
|
+ flush=True,
|
|
|
)
|
|
)
|
|
|
- )
|
|
|
|
|
- except Exception as exc:
|
|
|
|
|
- print(f"gap script demand failed: {exc}", file=sys.stderr)
|
|
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def run_festival_demand_job(config: FestivalDemandConfig) -> None:
|
|
|
|
|
+ query_date = _today_shanghai()
|
|
|
|
|
+ _run_with_same_day_retry(
|
|
|
|
|
+ job_name="festival_demand",
|
|
|
|
|
+ query_date=query_date,
|
|
|
|
|
+ run_once_fn=lambda: run_festival_demand_daily_job(config, query_date=query_date),
|
|
|
|
|
+ needs_retry=_festival_demand_needs_retry,
|
|
|
|
|
+ print_summary=print_festival_demand_summary,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def run_gap_script_demand_job(config: GapScriptDemandConfig) -> None:
|
|
|
|
|
+ query_date = _today_shanghai()
|
|
|
|
|
+ _run_with_same_day_retry(
|
|
|
|
|
+ job_name="gap_script_demand",
|
|
|
|
|
+ query_date=query_date,
|
|
|
|
|
+ run_once_fn=lambda: run_gap_script_demand_daily_job(config, query_date=query_date),
|
|
|
|
|
+ needs_retry=_gap_script_demand_needs_retry,
|
|
|
|
|
+ print_summary=print_gap_script_demand_summary,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_hot_content_job(scheduler: Any, config: FlowConfig) -> None:
|
|
def register_hot_content_job(scheduler: Any, config: FlowConfig) -> None:
|
|
@@ -218,36 +341,102 @@ def register_wxindex_heat_pattern_job(scheduler: Any, config: FlowConfig) -> Non
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def register_festival_demand_job(scheduler: Any, config: FestivalDemandConfig) -> None:
|
|
|
|
|
- scheduler.add_job(
|
|
|
|
|
- run_festival_demand_job,
|
|
|
|
|
- trigger="cron",
|
|
|
|
|
- hour=config.cron_hours,
|
|
|
|
|
- minute=config.cron_minute,
|
|
|
|
|
- timezone=SHANGHAI_TZ,
|
|
|
|
|
- args=[config],
|
|
|
|
|
- id="festival_demand",
|
|
|
|
|
- name="节日需求:活跃节日检测 + ODPS 需求词匹配",
|
|
|
|
|
- replace_existing=True,
|
|
|
|
|
- coalesce=True,
|
|
|
|
|
- max_instances=1,
|
|
|
|
|
|
|
+def _has_today_output_data(*, strategy: str, repository_cls: type[Any]) -> bool:
|
|
|
|
|
+ """判断当天输出 strategy 分区是否已有成功写入数据。"""
|
|
|
|
|
+ partition_dt = _today_shanghai().strftime("%Y%m%d")
|
|
|
|
|
+ flow_config = load_flow_config()
|
|
|
|
|
+ repository = repository_cls(flow_config.mysql)
|
|
|
|
|
+ try:
|
|
|
|
|
+ return bool(
|
|
|
|
|
+ repository.has_partition_data(
|
|
|
|
|
+ strategy=strategy,
|
|
|
|
|
+ partition_dt=partition_dt,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ repository.close()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _should_run_immediately_on_startup(
|
|
|
|
|
+ *,
|
|
|
|
|
+ strategy: str,
|
|
|
|
|
+ repository_cls: type[Any],
|
|
|
|
|
+ job_name: str,
|
|
|
|
|
+) -> bool:
|
|
|
|
|
+ """启动时判断:当天尚未成功写入则立刻执行,不只依赖 cron。"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ already_done = _has_today_output_data(
|
|
|
|
|
+ strategy=strategy,
|
|
|
|
|
+ repository_cls=repository_cls,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: startup check failed ({exc}), "
|
|
|
|
|
+ "will run immediately",
|
|
|
|
|
+ flush=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ if already_done:
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: today already succeeded "
|
|
|
|
|
+ f"(strategy={strategy}, date={_today_shanghai().isoformat()}), "
|
|
|
|
|
+ "skip startup run",
|
|
|
|
|
+ flush=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ print(
|
|
|
|
|
+ f"{job_name}: today not succeeded yet "
|
|
|
|
|
+ f"(strategy={strategy}, date={_today_shanghai().isoformat()}), "
|
|
|
|
|
+ "schedule startup run now",
|
|
|
|
|
+ flush=True,
|
|
|
)
|
|
)
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def register_festival_demand_job(scheduler: Any, config: FestivalDemandConfig) -> None:
|
|
|
|
|
+ job_kwargs: dict[str, Any] = {
|
|
|
|
|
+ "trigger": "cron",
|
|
|
|
|
+ "hour": config.cron_hours,
|
|
|
|
|
+ "minute": config.cron_minute,
|
|
|
|
|
+ "timezone": SHANGHAI_TZ,
|
|
|
|
|
+ "args": [config],
|
|
|
|
|
+ "id": "festival_demand",
|
|
|
|
|
+ "name": "节日需求:活跃节日检测 + ODPS 需求词匹配",
|
|
|
|
|
+ "replace_existing": True,
|
|
|
|
|
+ "coalesce": True,
|
|
|
|
|
+ "max_instances": 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ if _should_run_immediately_on_startup(
|
|
|
|
|
+ strategy=config.output_strategy,
|
|
|
|
|
+ repository_cls=FestivalDemandRepository,
|
|
|
|
|
+ job_name="festival_demand",
|
|
|
|
|
+ ):
|
|
|
|
|
+ job_kwargs["next_run_time"] = datetime.now(SHANGHAI_TZ)
|
|
|
|
|
+ scheduler.add_job(run_festival_demand_job, **job_kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_gap_script_demand_job(scheduler: Any, config: GapScriptDemandConfig) -> None:
|
|
def register_gap_script_demand_job(scheduler: Any, config: GapScriptDemandConfig) -> None:
|
|
|
- scheduler.add_job(
|
|
|
|
|
- run_gap_script_demand_job,
|
|
|
|
|
- trigger="cron",
|
|
|
|
|
- hour=config.cron_hours,
|
|
|
|
|
- minute=config.cron_minute,
|
|
|
|
|
- timezone=SHANGHAI_TZ,
|
|
|
|
|
- args=[config],
|
|
|
|
|
- id="gap_script_demand",
|
|
|
|
|
- name="当下供需gap:脚本主驱画面改造筛选",
|
|
|
|
|
- replace_existing=True,
|
|
|
|
|
- coalesce=True,
|
|
|
|
|
- max_instances=1,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ job_kwargs: dict[str, Any] = {
|
|
|
|
|
+ "trigger": "cron",
|
|
|
|
|
+ "hour": config.cron_hours,
|
|
|
|
|
+ "minute": config.cron_minute,
|
|
|
|
|
+ "timezone": SHANGHAI_TZ,
|
|
|
|
|
+ "args": [config],
|
|
|
|
|
+ "id": "gap_script_demand",
|
|
|
|
|
+ "name": "当下供需gap:脚本主驱画面改造筛选",
|
|
|
|
|
+ "replace_existing": True,
|
|
|
|
|
+ "coalesce": True,
|
|
|
|
|
+ "max_instances": 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ if _should_run_immediately_on_startup(
|
|
|
|
|
+ strategy=config.output_strategy,
|
|
|
|
|
+ repository_cls=GapScriptDemandRepository,
|
|
|
|
|
+ job_name="gap_script_demand",
|
|
|
|
|
+ ):
|
|
|
|
|
+ job_kwargs["next_run_time"] = datetime.now(SHANGHAI_TZ)
|
|
|
|
|
+ scheduler.add_job(run_gap_script_demand_job, **job_kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_scheduler() -> None:
|
|
def start_scheduler() -> None:
|
|
@@ -272,7 +461,9 @@ def start_scheduler() -> None:
|
|
|
f"wxindex_heat_pattern_cron="
|
|
f"wxindex_heat_pattern_cron="
|
|
|
f"{config.wxindex_heat_pattern_cron_hours}:{config.wxindex_heat_pattern_cron_minute:02d}, "
|
|
f"{config.wxindex_heat_pattern_cron_hours}:{config.wxindex_heat_pattern_cron_minute:02d}, "
|
|
|
f"festival_demand_cron={festival_config.cron_hours}:{festival_config.cron_minute:02d}, "
|
|
f"festival_demand_cron={festival_config.cron_hours}:{festival_config.cron_minute:02d}, "
|
|
|
- f"gap_script_demand_cron={gap_script_config.cron_hours}:{gap_script_config.cron_minute:02d}"
|
|
|
|
|
|
|
+ f"gap_script_demand_cron={gap_script_config.cron_hours}:{gap_script_config.cron_minute:02d}, "
|
|
|
|
|
+ f"same_day_retry_interval={SAME_DAY_RETRY_INTERVAL_SECONDS}s, "
|
|
|
|
|
+ "startup_check=festival_demand+gap_script_demand"
|
|
|
)
|
|
)
|
|
|
scheduler.start()
|
|
scheduler.start()
|
|
|
|
|
|