checkpoint: 调度任务存储落 PG——jobstore 归一 default + execute 写回真实触发 + 前端 store 字典更新
- ap_scheduler: 默认分组改 SQLAlchemyJobStore on PG,删 RedisJobStore 与 redis_instance;注释/告警口径同步 - node/service: jobstore 归一 default(兼容存量 sqlalchemy 名);cron/interval/date 写回 task_node.trigger/trigger_args(前端列表可见真实触发);run_job_now 用独立临时 id,不再覆盖节点既有排程 - enums: 删 APSCHEDULER_LOCK_KEY(jobstore 落 PG 后死锁) - sys_dict_data: sys_job_store 字典归一 default,删 sqlalchemy 档 - 前端: 任务表单 store 选项与 default 归一
This commit is contained in:
@@ -156,6 +156,12 @@ class NodeService:
|
|||||||
else:
|
else:
|
||||||
raise CustomException(msg=f"不支持的触发方式: {trigger}")
|
raise CustomException(msg=f"不支持的触发方式: {trigger}")
|
||||||
|
|
||||||
|
# 非一次性触发写回 task_node,任务列表可展示真实触发配置;
|
||||||
|
# apscheduler_jobs 表已有完整状态,此处仅补齐业务表字段
|
||||||
|
if trigger != "now":
|
||||||
|
obj.trigger = trigger
|
||||||
|
obj.trigger_args = trigger_args
|
||||||
|
|
||||||
return {"job_id": id, "status": "executed", "trigger": trigger}
|
return {"job_id": id, "status": "executed", "trigger": trigger}
|
||||||
|
|
||||||
async def batch_set_status(self, ids: list[int], status: int) -> None:
|
async def batch_set_status(self, ids: list[int], status: int) -> None:
|
||||||
@@ -171,14 +177,21 @@ class NodeService:
|
|||||||
# ── NodeModel 封装的任务添加方法 ────────────────────────────
|
# ── NodeModel 封装的任务添加方法 ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
|
def _add_job_with_trigger(job_info: NodeModel, trigger, job_id: str | None = None) -> Job:
|
||||||
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。"""
|
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。
|
||||||
|
|
||||||
|
job_id 为 None 时用节点 id 作调度任务 id(cron/interval/date 复用节点 id,便于原地更新);
|
||||||
|
立即执行(now)传独立临时 id,避免覆盖节点已有的定时排程。
|
||||||
|
"""
|
||||||
code_block = job_info.func
|
code_block = job_info.func
|
||||||
if not code_block or not code_block.strip():
|
if not code_block or not code_block.strip():
|
||||||
raise ValueError("任务代码块不能为空")
|
raise ValueError("任务代码块不能为空")
|
||||||
|
|
||||||
jobstore = job_info.jobstore or "sqlalchemy"
|
jobstore = job_info.jobstore or "default"
|
||||||
|
if jobstore == "sqlalchemy":
|
||||||
|
jobstore = "default" # 存量节点 jobstore="sqlalchemy"(旧持久化 store 名)归一为统一 PG store
|
||||||
executor = job_info.executor or "threadpool"
|
executor = job_info.executor or "threadpool"
|
||||||
|
job_id = job_id or str(job_info.id)
|
||||||
|
|
||||||
job_args = []
|
job_args = []
|
||||||
if job_info.args:
|
if job_info.args:
|
||||||
@@ -195,7 +208,7 @@ def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise ValueError(f"关键字参数JSON格式无效: {kwargs_str}")
|
raise ValueError(f"关键字参数JSON格式无效: {kwargs_str}")
|
||||||
|
|
||||||
SchedulerUtil.job_name_cache[str(job_info.id)] = job_info.name or ""
|
SchedulerUtil.job_name_cache[job_id] = job_info.name or ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = scheduler.add_job(
|
job = scheduler.add_job(
|
||||||
@@ -203,37 +216,42 @@ def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
|
|||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
args=[str(job_info.id), code_block, *job_args],
|
args=[str(job_info.id), code_block, *job_args],
|
||||||
kwargs=job_kwargs,
|
kwargs=job_kwargs,
|
||||||
id=str(job_info.id),
|
id=job_id,
|
||||||
name=job_info.name,
|
name=job_info.name,
|
||||||
coalesce=job_info.coalesce,
|
coalesce=job_info.coalesce,
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
jobstore=jobstore,
|
jobstore=jobstore,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
)
|
)
|
||||||
logger.info(f"任务 {job_info.id} 添加到 {jobstore} 存储器成功")
|
logger.info(f"任务 {job_id} 添加到 {jobstore} 存储器成功")
|
||||||
return job
|
return job
|
||||||
except ConflictingIdError:
|
except ConflictingIdError:
|
||||||
scheduler.remove_job(job_id=str(job_info.id), jobstore=jobstore)
|
scheduler.remove_job(job_id=job_id, jobstore=jobstore)
|
||||||
job = scheduler.add_job(
|
job = scheduler.add_job(
|
||||||
func=SchedulerUtil._task_wrapper,
|
func=SchedulerUtil._task_wrapper,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
args=[str(job_info.id), code_block, *job_args],
|
args=[str(job_info.id), code_block, *job_args],
|
||||||
kwargs=job_kwargs,
|
kwargs=job_kwargs,
|
||||||
id=str(job_info.id),
|
id=job_id,
|
||||||
name=job_info.name,
|
name=job_info.name,
|
||||||
coalesce=job_info.coalesce,
|
coalesce=job_info.coalesce,
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
jobstore=jobstore,
|
jobstore=jobstore,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
)
|
)
|
||||||
logger.info(f"任务 {job_info.id} 已存在,已移除旧任务并重新添加")
|
logger.info(f"任务 {job_id} 已存在,已移除旧任务并重新添加")
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def add_and_run_job_now(job_info: NodeModel) -> Job:
|
def add_and_run_job_now(job_info: NodeModel) -> Job:
|
||||||
"""立即执行任务(加入调度器并尽快触发一次)。"""
|
"""立即执行任务(加入调度器并尽快触发一次)。
|
||||||
|
|
||||||
|
用独立临时 id({节点id}_run_now_{时间戳})添加一次性 date 任务:
|
||||||
|
只触发一次、跑完自动移除,不覆盖该节点已有的 cron/interval 定时排程。
|
||||||
|
"""
|
||||||
|
temp_id = f"{job_info.id}_run_now_{datetime.now().timestamp()}"
|
||||||
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1))
|
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1))
|
||||||
return _add_job_with_trigger(job_info, trigger)
|
return _add_job_with_trigger(job_info, trigger, job_id=temp_id)
|
||||||
|
|
||||||
|
|
||||||
def add_cron_job(
|
def add_cron_job(
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ class RedisInitKeyConfig(Enum):
|
|||||||
EMAIL_CODES = {"key": "email_codes", "remark": "邮箱验证码"}
|
EMAIL_CODES = {"key": "email_codes", "remark": "邮箱验证码"}
|
||||||
SYSTEM_CONFIG = {"key": "system_config", "remark": "系统配置"}
|
SYSTEM_CONFIG = {"key": "system_config", "remark": "系统配置"}
|
||||||
SYSTEM_DICT = {"key": "system_dict", "remark": "数据字典"}
|
SYSTEM_DICT = {"key": "system_dict", "remark": "数据字典"}
|
||||||
APSCHEDULER_LOCK_KEY = {"key": "scheduler_job_lock", "remark": "定时任务初始化锁"}
|
|
||||||
AI_MODEL_CONFIG = {"key": "ai_model_config", "remark": "用户AI模型配置"}
|
AI_MODEL_CONFIG = {"key": "ai_model_config", "remark": "用户AI模型配置"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -18,13 +18,11 @@ from apscheduler.executors.asyncio import AsyncIOExecutor
|
|||||||
from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor
|
from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor
|
||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
from apscheduler.jobstores.memory import MemoryJobStore
|
from apscheduler.jobstores.memory import MemoryJobStore
|
||||||
from apscheduler.jobstores.redis import RedisJobStore
|
|
||||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.date import DateTrigger
|
from apscheduler.triggers.date import DateTrigger
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from redis.asyncio import Redis
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config.setting import settings
|
from app.config.setting import settings
|
||||||
@@ -37,15 +35,9 @@ JOB_STATUS_FAILED = 3
|
|||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
scheduler.configure(
|
scheduler.configure(
|
||||||
jobstores={
|
jobstores={
|
||||||
"default": RedisJobStore(
|
# 默认分组 = PostgreSQL(apscheduler_jobs 表):任务持久化,随 pg_data 每日备份;
|
||||||
host=settings.REDIS_HOST,
|
# 与业务主库同生共死,不再依赖可降级/无备份的 Redis(多 Worker 场景本系统不适用,见运营手册)
|
||||||
port=int(settings.REDIS_PORT),
|
"default": SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
|
||||||
username=settings.REDIS_USER or None,
|
|
||||||
password=settings.REDIS_PASSWORD or None,
|
|
||||||
db=int(settings.REDIS_DB_NAME),
|
|
||||||
protocol=2,
|
|
||||||
),
|
|
||||||
"sqlalchemy": SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
|
|
||||||
"memory": MemoryJobStore(),
|
"memory": MemoryJobStore(),
|
||||||
},
|
},
|
||||||
executors={
|
executors={
|
||||||
@@ -64,7 +56,6 @@ scheduler.configure(
|
|||||||
class SchedulerUtil:
|
class SchedulerUtil:
|
||||||
"""定时任务 SDK — 仅封装 APScheduler 核心操作,不含业务逻辑(无 ORM/实体引用)。"""
|
"""定时任务 SDK — 仅封装 APScheduler 核心操作,不含业务逻辑(无 ORM/实体引用)。"""
|
||||||
|
|
||||||
redis_instance: Redis | None = None
|
|
||||||
job_name_cache: dict[str, str | tuple[str, str]] = {}
|
job_name_cache: dict[str, str | tuple[str, str]] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -129,15 +120,13 @@ class SchedulerUtil:
|
|||||||
logger.info("所有任务已从调度器中移除")
|
logger.info("所有任务已从调度器中移除")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def init_scheduler(cls, redis: Redis | None = None) -> None:
|
async def init_scheduler(cls) -> None:
|
||||||
"""应用启动时初始化定时任务调度器(含系统级周期任务注册)。
|
"""应用启动时初始化定时任务调度器(含系统级周期任务注册)。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
- None
|
- None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if redis:
|
|
||||||
cls.redis_instance = redis
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
scheduler.add_listener(cls._dispatch_job_event, EVENT_ALL)
|
scheduler.add_listener(cls._dispatch_job_event, EVENT_ALL)
|
||||||
scheduler.resume()
|
scheduler.resume()
|
||||||
@@ -177,7 +166,7 @@ class SchedulerUtil:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def shutdown(cls, wait: bool = False) -> None:
|
def shutdown(cls, wait: bool = False) -> None:
|
||||||
# Redis 不可达时 start()/add_job 可能失败导致调度器未真正运行:
|
# jobstore 已落 PG:数据库不可达时 start()/add_job 可能失败导致调度器未真正运行:
|
||||||
# 对未运行实例调用 shutdown 会抛 SchedulerNotRunningError,须先判状态
|
# 对未运行实例调用 shutdown 会抛 SchedulerNotRunningError,须先判状态
|
||||||
if not scheduler.running:
|
if not scheduler.running:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
|
|||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠️ Redis 不可用,以降级模式启动:参数/字典读接口回源数据库;"
|
"⚠️ Redis 不可用,以降级模式启动:参数/字典读接口回源数据库;"
|
||||||
"登录会话/在线监控/AI配置/调度任务接口将返回 503"
|
"登录会话/在线监控/AI配置接口将返回 503(调度任务 jobstore 已落 PG,不受影响)"
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.utils.dict_util import preload_value2label_cache
|
from app.utils.dict_util import preload_value2label_cache
|
||||||
@@ -50,11 +50,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
|
|||||||
|
|
||||||
scheduler_ready = False
|
scheduler_ready = False
|
||||||
try:
|
try:
|
||||||
await SchedulerUtil.init_scheduler(redis=app.state.redis)
|
await SchedulerUtil.init_scheduler()
|
||||||
scheduler_ready = SchedulerUtil.is_running()
|
scheduler_ready = SchedulerUtil.is_running()
|
||||||
logger.info("✅ 定时任务调度器初始化完成")
|
logger.info("✅ 定时任务调度器初始化完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Redis 不可达时默认 jobstore 连不上:不阻断启动,任务接口降级为 503
|
# 调度器初始化失败(jobstore 已落 PG,与主库同生共死):不阻断启动
|
||||||
SchedulerUtil.shutdown(wait=False)
|
SchedulerUtil.shutdown(wait=False)
|
||||||
logger.error("❌ 定时任务调度器初始化失败(继续启动,任务接口将返回 503): {}", e)
|
logger.error("❌ 定时任务调度器初始化失败(继续启动,任务接口将返回 503): {}", e)
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"dict_sort": 1,
|
"dict_sort": 1,
|
||||||
"dict_label": "默认(Redis)",
|
"dict_label": "默认(数据库)",
|
||||||
"dict_value": "default",
|
"dict_value": "default",
|
||||||
"dict_type": "sys_job_store",
|
"dict_type": "sys_job_store",
|
||||||
"dict_type_id": 6,
|
"dict_type_id": 6,
|
||||||
@@ -237,22 +237,10 @@
|
|||||||
"list_class": null,
|
"list_class": null,
|
||||||
"is_default": true,
|
"is_default": true,
|
||||||
"status": 0,
|
"status": 0,
|
||||||
"description": "默认分组,支持多Worker部署"
|
"description": "默认分组(PostgreSQL 持久化,随主库每日备份)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"dict_sort": 2,
|
"dict_sort": 2,
|
||||||
"dict_label": "数据库(Sqlalchemy)",
|
|
||||||
"dict_value": "sqlalchemy",
|
|
||||||
"dict_type": "sys_job_store",
|
|
||||||
"dict_type_id": 6,
|
|
||||||
"css_class": "",
|
|
||||||
"list_class": null,
|
|
||||||
"is_default": false,
|
|
||||||
"status": 0,
|
|
||||||
"description": "数据库分组,持久化存储"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dict_sort": 3,
|
|
||||||
"dict_label": "内存(Memory)",
|
"dict_label": "内存(Memory)",
|
||||||
"dict_value": "memory",
|
"dict_value": "memory",
|
||||||
"dict_type": "sys_job_store",
|
"dict_type": "sys_job_store",
|
||||||
|
|||||||
@@ -502,6 +502,27 @@ const {
|
|||||||
label: "执行器",
|
label: "执行器",
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prop: "trigger",
|
||||||
|
label: "触发方式",
|
||||||
|
minWidth: 100,
|
||||||
|
formatter: (row: NodeTable) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
now: "立即执行",
|
||||||
|
cron: "Cron",
|
||||||
|
interval: "间隔",
|
||||||
|
date: "定时",
|
||||||
|
};
|
||||||
|
return row.trigger ? (map[row.trigger] ?? row.trigger) : "-";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: "trigger_args",
|
||||||
|
label: "触发配置",
|
||||||
|
minWidth: 160,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: (row: NodeTable) => row.trigger_args || "-",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prop: "created_time",
|
prop: "created_time",
|
||||||
label: "创建时间",
|
label: "创建时间",
|
||||||
@@ -794,7 +815,7 @@ const initialFormData: Partial<NodeForm> = {
|
|||||||
id: undefined,
|
id: undefined,
|
||||||
name: "",
|
name: "",
|
||||||
code: undefined,
|
code: undefined,
|
||||||
jobstore: "sqlalchemy",
|
jobstore: "default",
|
||||||
executor: "default",
|
executor: "default",
|
||||||
func: defaultCodeBlock,
|
func: defaultCodeBlock,
|
||||||
args: undefined,
|
args: undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user