签入版本
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
@@ -50,6 +50,7 @@
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
@@ -60,6 +61,7 @@ from apscheduler.jobstores.base import JobLookupError
|
||||
|
||||
from config import INTERNAL_API_KEY, FLASK_SITE_DOMAIN
|
||||
from src.common.utils.model_utils import model_to_dict
|
||||
from src.core.redis_client import redis
|
||||
|
||||
# ============================================================
|
||||
# 全局变量和日志配置
|
||||
@@ -72,6 +74,10 @@ logger = logging.getLogger(__name__)
|
||||
# 调度器实例(延迟初始化)
|
||||
_scheduler = None
|
||||
|
||||
# 调度线程内可用的 Flask app(init_scheduler 在 app 上下文内捕获,供写任务日志用)。
|
||||
# APScheduler 的 cron 回调运行在后台线程,没有请求上下文,写库前需手动推 app 上下文。
|
||||
_SCHEDULER_APP = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 调度器实例获取函数
|
||||
@@ -98,6 +104,46 @@ def get_scheduler():
|
||||
# 创建全局调度器实例
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# ============================================================
|
||||
# 分布式任务锁(防重复执行)
|
||||
# ============================================================
|
||||
|
||||
#: 锁最长持有时间(秒)。作为进程崩溃时的兜底,超时自动释放,
|
||||
#: 避免长期占用导致任务被永久跳过。
|
||||
_JOB_LOCK_TTL_SECONDS = 300
|
||||
|
||||
|
||||
class _JobExecutionLock:
|
||||
"""
|
||||
基于 Redis 的分布式任务锁。
|
||||
|
||||
背景:调度器是进程内的 BackgroundScheduler,gunicorn 多 worker / 多实例
|
||||
部署时每个进程都会注册并触发同一 cron 任务。通过 Redis SET NX EX 原子操作
|
||||
保证同一 job_id 在同一触发周期内只有一个实例真正执行。
|
||||
|
||||
acquire() 失败(Redis 不可用)时降级放行,避免锁服务故障导致任务停摆。
|
||||
"""
|
||||
|
||||
def __init__(self, job_id: int):
|
||||
self.key = f"scheduler:job_lock:{job_id}"
|
||||
self.token = uuid.uuid4().hex
|
||||
|
||||
def acquire(self) -> bool:
|
||||
try:
|
||||
# 原子 SET key token NX EX ttl:并发下仅一个实例能成功
|
||||
return bool(redis.client.set(self.key, self.token, nx=True, ex=_JOB_LOCK_TTL_SECONDS))
|
||||
except Exception as e:
|
||||
logger.error(f"获取调度任务锁异常 [ID: {self.key}]: {str(e)}")
|
||||
return True
|
||||
|
||||
def release(self):
|
||||
try:
|
||||
# 仅删除自己持有的锁,避免误删其他实例刚获取的新锁
|
||||
if redis.client.get(self.key) == self.token:
|
||||
redis.client.delete(self.key)
|
||||
except Exception as e:
|
||||
logger.error(f"释放调度任务锁异常 [ID: {self.key}]: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# URL 构建工具函数
|
||||
@@ -140,6 +186,68 @@ def build_full_url(url: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 任务执行日志(每次实际执行后写入 flask_job_log)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _extract_log_fields(result) -> tuple:
|
||||
"""从执行结果提取 (success, message, error) 三元组,兼容 URL/触发器两种返回形态。
|
||||
|
||||
- URL 任务:result = {"success": bool, "status_code":..., "data":...}
|
||||
- 触发器任务:result = {"success": True, "data": {"success": bool, "message": ...}}
|
||||
(真正成败在 data 里,由 executor 返回)
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return True, str(result), None
|
||||
success = result.get("success")
|
||||
data = result.get("data")
|
||||
if isinstance(data, dict) and "success" in data:
|
||||
success = data.get("success")
|
||||
message = result.get("message") or (data.get("message") if isinstance(data, dict) else None) or ""
|
||||
error = result.get("error")
|
||||
if not message and data is not None:
|
||||
message = str(data)
|
||||
return bool(success), str(message or ""), (str(error) if error else None)
|
||||
|
||||
|
||||
def _write_job_log(job_id, success, message, error, start, end):
|
||||
"""在 app 上下文内写任务日志(尽力而为,写失败不影响任务执行结果)。"""
|
||||
if _SCHEDULER_APP is None:
|
||||
logger.error("调度器 app 未绑定,跳过任务日志写入 [ID: %s]", job_id)
|
||||
return
|
||||
try:
|
||||
with _SCHEDULER_APP.app_context():
|
||||
from src.modules.job_log.service import write_job_log
|
||||
write_job_log(
|
||||
job_id, success=success, message=message, error=error,
|
||||
start_time=start, end_time=end,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"写入任务日志异常 [ID: {job_id}]: {e}")
|
||||
|
||||
|
||||
def _execute_and_log(job_id, func, *args, **kwargs):
|
||||
"""执行任务并自动记录执行日志(开始/结束时间、耗时、结果、异常)。
|
||||
|
||||
无论成功失败(含异常)都会写一条 flask_job_log;返回执行结果给调用方。
|
||||
"""
|
||||
start = datetime.now()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
success, message, error = _extract_log_fields(result)
|
||||
except Exception as e:
|
||||
result = {"success": False, "error": str(e)}
|
||||
success, message, error = False, str(e), str(e)
|
||||
logger.error(f"执行任务异常 [ID: {job_id}]: {e}")
|
||||
end = datetime.now()
|
||||
_write_job_log(job_id, success, message, error, start, end)
|
||||
elapsed = (end - start).total_seconds()
|
||||
status = "成功" if success else "失败"
|
||||
print(f"[JobScheduler] 任务 [ID: {job_id}] 执行{status},耗时 {elapsed:.2f}s,结果: {message or error}")
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 任务执行核心函数
|
||||
# ============================================================
|
||||
@@ -167,28 +275,45 @@ def execute_job(
|
||||
Returns:
|
||||
Dict: 执行结果,包含 success 和 data/error 字段
|
||||
"""
|
||||
logger.debug(f"execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}")
|
||||
sys.stdout.flush()
|
||||
|
||||
logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]")
|
||||
print(f"[JobScheduler] 开始执行任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}, 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
|
||||
|
||||
# 根据任务类型分发执行(同步路径持有分布式锁,防多实例重复执行)
|
||||
if url:
|
||||
if is_sync == 1:
|
||||
return _run_with_job_lock(job_id, _execute_by_url, job_id, url, is_sync, params)
|
||||
# 异步路径:父线程立即返回,锁由子线程获取(见 _send_async_request)
|
||||
return _execute_by_url(job_id, url, is_sync, params)
|
||||
|
||||
if job_trigger:
|
||||
return _run_with_job_lock(job_id, _execute_by_trigger, job_trigger, job_id, params)
|
||||
|
||||
# 无效的任务配置
|
||||
return {"success": False, "error": "任务既没有URL也没有触发器"}
|
||||
|
||||
|
||||
def _run_with_job_lock(job_id: int, func, *args, **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
在分布式锁保护下执行任务函数,保证同一任务只被一个调度器实例执行。
|
||||
|
||||
调用方负责传入实际执行函数;锁的获取/释放、异常兜底在此统一处理。
|
||||
"""
|
||||
lock = _JobExecutionLock(job_id)
|
||||
if not lock.acquire():
|
||||
logger.info(f"定时任务 [ID: {job_id}] 已被其他调度器实例执行,本次跳过")
|
||||
print(f"[JobScheduler] 任务 [ID: {job_id}] 已被其他实例占用,本次跳过")
|
||||
return {"success": False, "error": "任务已被其他调度器实例执行"}
|
||||
|
||||
try:
|
||||
print(f"[scheduler] execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}")
|
||||
sys.stdout.flush()
|
||||
|
||||
logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]")
|
||||
|
||||
# 根据任务类型分发执行
|
||||
if url:
|
||||
# URL方式:发送HTTP请求
|
||||
result = _execute_by_url(job_id, url, is_sync, params)
|
||||
elif job_trigger:
|
||||
# 触发器方式:调用内部执行器
|
||||
result = _execute_by_trigger(job_trigger, job_id, params)
|
||||
else:
|
||||
# 无效的任务配置
|
||||
result = {"success": False, "error": "任务既没有URL也没有触发器"}
|
||||
|
||||
return result
|
||||
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"执行任务异常 [ID: {job_id}]: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -238,7 +363,7 @@ def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict:
|
||||
Returns:
|
||||
Dict: 包含响应状态码和数据的执行结果
|
||||
"""
|
||||
try:
|
||||
def _do() -> Dict:
|
||||
logger.info(f"发送同步请求 [ID: {job_id}, URL: {url}]")
|
||||
|
||||
# 准备请求头
|
||||
@@ -263,8 +388,8 @@ def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict:
|
||||
|
||||
return {"success": True, "status_code": response.status_code, "data": result_data}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
# 同步执行(真实干活的地方):成功/失败/异常都写任务日志
|
||||
return _execute_and_log(job_id, _do)
|
||||
|
||||
|
||||
def _send_async_request(url: str, data: Dict, job_id: int) -> Dict:
|
||||
@@ -285,19 +410,35 @@ def _send_async_request(url: str, data: Dict, job_id: int) -> Dict:
|
||||
|
||||
def async_request():
|
||||
"""异步请求执行函数(在独立线程中运行)"""
|
||||
# 多实例下每个 worker 都会触发该线程,必须加锁保证只执行一次
|
||||
lock = _JobExecutionLock(job_id)
|
||||
if not lock.acquire():
|
||||
logger.info(f"异步任务 [ID: {job_id}] 已被其他调度器实例执行,本次跳过")
|
||||
print(f"[JobScheduler] 异步任务 [ID: {job_id}] 已被其他实例占用,本次跳过")
|
||||
return
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Scheduler-Key": INTERNAL_API_KEY,
|
||||
}
|
||||
resp = requests.post(url, json=data, timeout=30, headers=headers)
|
||||
logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
|
||||
def _do() -> Dict:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Scheduler-Key": INTERNAL_API_KEY,
|
||||
}
|
||||
resp = requests.post(url, json=data, timeout=30, headers=headers)
|
||||
logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
|
||||
print(f"[JobScheduler] 异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
|
||||
return {"success": True, "status_code": resp.status_code,
|
||||
"message": f"异步任务完成,状态码 {resp.status_code}"}
|
||||
|
||||
# 异步真实干活的地方:成功/失败/异常都写任务日志
|
||||
_execute_and_log(job_id, _do)
|
||||
except Exception as e:
|
||||
logger.error(f"异步任务异常 [ID: {job_id}]: {str(e)}")
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
# 创建并启动独立线程执行异步请求
|
||||
thread = threading.Thread(target=async_request)
|
||||
thread.start()
|
||||
print(f"[JobScheduler] 异步任务已触发 [ID: {job_id}, URL: {url}]")
|
||||
|
||||
return {"success": True, "status": "triggered"}
|
||||
|
||||
@@ -316,13 +457,14 @@ def _execute_by_trigger(trigger: str, job_id: int, params: Optional[Dict] = None
|
||||
Returns:
|
||||
Dict: 执行结果
|
||||
"""
|
||||
try:
|
||||
def _do() -> Dict:
|
||||
from src.modules.job.executor import job_executor
|
||||
|
||||
result = job_executor.execute(trigger, job_id=job_id, **(params or {}))
|
||||
return {"success": True, "trigger": trigger, "data": result}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# 触发器任务真实干活的地方:成功/失败/异常都写任务日志
|
||||
return _execute_and_log(job_id, _do)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -361,7 +503,7 @@ def add_job_to_scheduler(job_dict: dict) -> bool:
|
||||
job_trigger = job_dict.get("jobTrigger")
|
||||
job_name = job_dict.get("jobName")
|
||||
|
||||
print(f"[scheduler] add_job id={job_id} status={job_status} cron='{job_cron}'")
|
||||
logger.debug(f"add_job id={job_id} status={job_status} cron='{job_cron}'")
|
||||
|
||||
# ============================================================
|
||||
# 2. 参数校验
|
||||
@@ -418,6 +560,7 @@ def add_job_to_scheduler(job_dict: dict) -> bool:
|
||||
)
|
||||
|
||||
logger.info(f"任务已添加到调度器 [ID: {job_id}, cron: {job_cron}]")
|
||||
print(f"[JobScheduler] 任务已添加到调度器 [ID: {job_id}, 名称: {job_name}, cron: {job_cron}, 下次执行: {next_run}]")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -528,6 +671,13 @@ def init_scheduler() -> int:
|
||||
Returns:
|
||||
int: 成功加载的任务数量
|
||||
"""
|
||||
global _SCHEDULER_APP
|
||||
# 捕获 Flask app(供调度线程写任务日志时推 app 上下文;Scheduler.init_app 在 app 上下文内调用)
|
||||
try:
|
||||
from flask import current_app
|
||||
_SCHEDULER_APP = current_app._get_current_object()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.modules.job.models import Job
|
||||
|
||||
@@ -535,6 +685,7 @@ def init_scheduler() -> int:
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("调度器已启动")
|
||||
print(f"[JobScheduler] 调度器已启动 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
|
||||
|
||||
# 查询所有启用的任务
|
||||
jobs = Job.query.filter(Job.status == 1, Job.is_delete == 0).all()
|
||||
@@ -553,6 +704,7 @@ def init_scheduler() -> int:
|
||||
success_count += 1
|
||||
|
||||
logger.info(f"调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务")
|
||||
print(f"[JobScheduler] 调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务")
|
||||
return success_count
|
||||
|
||||
except Exception as e:
|
||||
@@ -572,6 +724,7 @@ def reload_scheduler() -> int:
|
||||
try:
|
||||
# 清空所有现有任务
|
||||
scheduler.remove_all_jobs()
|
||||
print(f"[JobScheduler] 开始重新加载所有定时任务 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
|
||||
# 重新初始化
|
||||
return init_scheduler()
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user