|
@@ -15,6 +15,15 @@ from typing import Any, Dict, Optional
|
|
|
import aiohttp
|
|
import aiohttp
|
|
|
|
|
|
|
|
from src.config.xxljob import XxlJobConfig
|
|
from src.config.xxljob import XxlJobConfig
|
|
|
|
|
+from src.infra.shared.tools import generate_task_trace_id
|
|
|
|
|
+from src.infra.observability.context import _current_trace_id, get_log_service
|
|
|
|
|
+from src.infra.observability import (
|
|
|
|
|
+ LogCategory,
|
|
|
|
|
+ LogLevel,
|
|
|
|
|
+ LogStatus,
|
|
|
|
|
+ LogRecord,
|
|
|
|
|
+ TaskEvent,
|
|
|
|
|
+)
|
|
|
from .xxl_decorator import xxl_registry
|
|
from .xxl_decorator import xxl_registry
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
@@ -129,7 +138,7 @@ class XxlJobExecutor:
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
|
- async def _callback(self, log_id: int, log_date_time: int, result: dict) -> None:
|
|
|
|
|
|
|
+ async def _callback(self, log_id: int, log_date_time: int, result: dict) -> bool:
|
|
|
"""向 Admin 回调上报任务执行结果 —— POST /api/callback
|
|
"""向 Admin 回调上报任务执行结果 —— POST /api/callback
|
|
|
|
|
|
|
|
对标 Java 版的 TriggerCallbackThread.callback()。
|
|
对标 Java 版的 TriggerCallbackThread.callback()。
|
|
@@ -138,10 +147,13 @@ class XxlJobExecutor:
|
|
|
log_id: XXL-JOB 调度日志 ID
|
|
log_id: XXL-JOB 调度日志 ID
|
|
|
log_date_time: 调度时间戳(毫秒)
|
|
log_date_time: 调度时间戳(毫秒)
|
|
|
result: handler 返回的 {"code": 200, "msg": "..."} 或异常结果
|
|
result: handler 返回的 {"code": 200, "msg": "..."} 或异常结果
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ True 如果至少向一个 Admin 成功上报,False 全部失败
|
|
|
"""
|
|
"""
|
|
|
if not log_id:
|
|
if not log_id:
|
|
|
logger.warning("Skip callback: logId is empty")
|
|
logger.warning("Skip callback: logId is empty")
|
|
|
- return
|
|
|
|
|
|
|
+ return False
|
|
|
|
|
|
|
|
body = {
|
|
body = {
|
|
|
"logId": log_id,
|
|
"logId": log_id,
|
|
@@ -159,13 +171,14 @@ class XxlJobExecutor:
|
|
|
if resp.status == 200:
|
|
if resp.status == 200:
|
|
|
data = await resp.json()
|
|
data = await resp.json()
|
|
|
if data.get("code") == 200:
|
|
if data.get("code") == 200:
|
|
|
- return
|
|
|
|
|
|
|
+ return True
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
"Callback to %s rejected: %s", admin_url, data.get("msg")
|
|
"Callback to %s rejected: %s", admin_url, data.get("msg")
|
|
|
)
|
|
)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("Callback to %s failed: %s", admin_url, e)
|
|
logger.warning("Callback to %s failed: %s", admin_url, e)
|
|
|
logger.error("All callback attempts failed for logId=%s", log_id)
|
|
logger.error("All callback attempts failed for logId=%s", log_id)
|
|
|
|
|
+ return False
|
|
|
|
|
|
|
|
async def _heartbeat_loop(self) -> None:
|
|
async def _heartbeat_loop(self) -> None:
|
|
|
"""定期心跳:每隔 registry_interval 秒重新注册(续约)"""
|
|
"""定期心跳:每隔 registry_interval 秒重新注册(续约)"""
|
|
@@ -223,8 +236,13 @@ class XxlJobExecutor:
|
|
|
f"Registered: {', '.join(registered) if registered else '(none)'}",
|
|
f"Registered: {', '.join(registered) if registered else '(none)'}",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ # 生成 trace_id 并写入 ContextVar,穿透到 handler → service 全链路
|
|
|
|
|
+ trace_id = generate_task_trace_id()
|
|
|
|
|
+ _current_trace_id.set(trace_id)
|
|
|
|
|
+
|
|
|
async def _execute_and_callback():
|
|
async def _execute_and_callback():
|
|
|
"""后台执行 handler 并回调 Admin 上报结果"""
|
|
"""后台执行 handler 并回调 Admin 上报结果"""
|
|
|
|
|
+ _log_task_received(trace_id, handler_name, job_id, param)
|
|
|
try:
|
|
try:
|
|
|
if timeout and timeout > 0:
|
|
if timeout and timeout > 0:
|
|
|
result = await asyncio.wait_for(handler(param), timeout=timeout)
|
|
result = await asyncio.wait_for(handler(param), timeout=timeout)
|
|
@@ -234,17 +252,26 @@ class XxlJobExecutor:
|
|
|
logger.error(
|
|
logger.error(
|
|
|
"Job %s(id=%s) timeout after %ss", handler_name, job_id, timeout
|
|
"Job %s(id=%s) timeout after %ss", handler_name, job_id, timeout
|
|
|
)
|
|
)
|
|
|
|
|
+ _log_task_timeout(trace_id, handler_name, job_id, timeout)
|
|
|
result = {"code": 500, "msg": f"timeout after {timeout}s"}
|
|
result = {"code": 500, "msg": f"timeout after {timeout}s"}
|
|
|
except asyncio.CancelledError:
|
|
except asyncio.CancelledError:
|
|
|
logger.info("Job %s(id=%s) cancelled", handler_name, job_id)
|
|
logger.info("Job %s(id=%s) cancelled", handler_name, job_id)
|
|
|
|
|
+ _log_task_cancelled(trace_id, handler_name, job_id)
|
|
|
result = {"code": 500, "msg": "job cancelled"}
|
|
result = {"code": 500, "msg": "job cancelled"}
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.exception("Job %s(id=%s) failed", handler_name, job_id)
|
|
logger.exception("Job %s(id=%s) failed", handler_name, job_id)
|
|
|
|
|
+ _log_task_failed(trace_id, handler_name, job_id, e)
|
|
|
result = {"code": 500, "msg": str(e)}
|
|
result = {"code": 500, "msg": str(e)}
|
|
|
|
|
|
|
|
# 回调 Admin 上报执行结果
|
|
# 回调 Admin 上报执行结果
|
|
|
- await self._callback(log_id, log_date_time, result)
|
|
|
|
|
- logger.info("Job %s(id=%s) callback done: %s", handler_name, job_id, result)
|
|
|
|
|
|
|
+ callback_ok = await self._callback(log_id, log_date_time, result)
|
|
|
|
|
+ if callback_ok:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Job %s(id=%s) callback succeeded: %s",
|
|
|
|
|
+ handler_name,
|
|
|
|
|
+ job_id,
|
|
|
|
|
+ result,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
task = asyncio.create_task(_execute_and_callback())
|
|
task = asyncio.create_task(_execute_and_callback())
|
|
|
if job_id is not None:
|
|
if job_id is not None:
|
|
@@ -254,9 +281,10 @@ class XxlJobExecutor:
|
|
|
task.add_done_callback(lambda t: self._running_jobs.pop(job_id, None))
|
|
task.add_done_callback(lambda t: self._running_jobs.pop(job_id, None))
|
|
|
|
|
|
|
|
logger.info(
|
|
logger.info(
|
|
|
- "Job %s(id=%s) dispatched, will callback when done",
|
|
|
|
|
|
|
+ "Job %s(id=%s) dispatched, trace_id=%s, will callback when done",
|
|
|
handler_name,
|
|
handler_name,
|
|
|
job_id,
|
|
job_id,
|
|
|
|
|
+ trace_id,
|
|
|
)
|
|
)
|
|
|
return {"code": 200, "msg": None}
|
|
return {"code": 200, "msg": None}
|
|
|
|
|
|
|
@@ -336,3 +364,91 @@ class XxlJobExecutor:
|
|
|
return ip
|
|
return ip
|
|
|
except Exception:
|
|
except Exception:
|
|
|
return "127.0.0.1"
|
|
return "127.0.0.1"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
+# SLS 日志辅助函数 —— XXL-JOB 生命周期事件
|
|
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _log_task_received(
|
|
|
|
|
+ trace_id: str, handler_name: str, job_id: int, param: str
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """记录 task.received 到 SLS(非阻塞,fire-and-forget)"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ ls = get_log_service()
|
|
|
|
|
+ record = LogRecord(
|
|
|
|
|
+ level=LogLevel.INFO,
|
|
|
|
|
+ category=LogCategory.TASK,
|
|
|
|
|
+ event=TaskEvent.TASK_RECEIVED,
|
|
|
|
|
+ message=f"XXL-JOB 调度到达: {handler_name}",
|
|
|
|
|
+ trace_id=trace_id,
|
|
|
|
|
+ task_name=handler_name,
|
|
|
|
|
+ extra={"job_id": job_id, "param": param},
|
|
|
|
|
+ )
|
|
|
|
|
+ asyncio.create_task(ls.log(record))
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass # LogService 未注入(测试环境),静默跳过
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _log_task_timeout(
|
|
|
|
|
+ trace_id: str, handler_name: str, job_id: int, timeout: int
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """记录 task.timeout"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ ls = get_log_service()
|
|
|
|
|
+ record = LogRecord(
|
|
|
|
|
+ level=LogLevel.ERROR,
|
|
|
|
|
+ category=LogCategory.TASK,
|
|
|
|
|
+ event=TaskEvent.TASK_TIMEOUT,
|
|
|
|
|
+ message=f"任务超时: {handler_name} (timeout={timeout}s)",
|
|
|
|
|
+ trace_id=trace_id,
|
|
|
|
|
+ task_name=handler_name,
|
|
|
|
|
+ status=LogStatus.TIMEOUT,
|
|
|
|
|
+ extra={"job_id": job_id, "timeout_s": timeout},
|
|
|
|
|
+ )
|
|
|
|
|
+ asyncio.create_task(ls.log(record))
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _log_task_cancelled(trace_id: str, handler_name: str, job_id: int) -> None:
|
|
|
|
|
+ """记录 task.cancelled"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ ls = get_log_service()
|
|
|
|
|
+ record = LogRecord(
|
|
|
|
|
+ level=LogLevel.WARN,
|
|
|
|
|
+ category=LogCategory.TASK,
|
|
|
|
|
+ event=TaskEvent.TASK_CANCELLED,
|
|
|
|
|
+ message=f"任务被取消: {handler_name}",
|
|
|
|
|
+ trace_id=trace_id,
|
|
|
|
|
+ task_name=handler_name,
|
|
|
|
|
+ status=LogStatus.CANCELLED,
|
|
|
|
|
+ extra={"job_id": job_id},
|
|
|
|
|
+ )
|
|
|
|
|
+ asyncio.create_task(ls.log(record))
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _log_task_failed(
|
|
|
|
|
+ trace_id: str, handler_name: str, job_id: int, error: Exception
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """记录 task.failed(handler 未捕获的顶层异常)"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ ls = get_log_service()
|
|
|
|
|
+ record = LogRecord(
|
|
|
|
|
+ level=LogLevel.ERROR,
|
|
|
|
|
+ category=LogCategory.TASK,
|
|
|
|
|
+ event=TaskEvent.TASK_FAILED,
|
|
|
|
|
+ message=str(error),
|
|
|
|
|
+ trace_id=trace_id,
|
|
|
|
|
+ task_name=handler_name,
|
|
|
|
|
+ status=LogStatus.FAILED,
|
|
|
|
|
+ error_type=type(error).__name__,
|
|
|
|
|
+ error_message=str(error),
|
|
|
|
|
+ extra={"job_id": job_id},
|
|
|
|
|
+ )
|
|
|
|
|
+ asyncio.create_task(ls.log(record))
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|