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:
34047007@qq.com
2026-08-07 21:47:38 +08:00
parent 86126ea16f
commit 73e25050df
6 changed files with 61 additions and 46 deletions
@@ -156,6 +156,12 @@ class NodeService:
else:
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}
async def batch_set_status(self, ids: list[int], status: int) -> None:
@@ -171,14 +177,21 @@ class NodeService:
# ── NodeModel 封装的任务添加方法 ────────────────────────────
def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。"""
def _add_job_with_trigger(job_info: NodeModel, trigger, job_id: str | None = None) -> Job:
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。
job_id 为 None 时用节点 id 作调度任务 idcron/interval/date 复用节点 id,便于原地更新);
立即执行(now)传独立临时 id,避免覆盖节点已有的定时排程。
"""
code_block = job_info.func
if not code_block or not code_block.strip():
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"
job_id = job_id or str(job_info.id)
job_args = []
if job_info.args:
@@ -195,7 +208,7 @@ def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
except json.JSONDecodeError:
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:
job = scheduler.add_job(
@@ -203,37 +216,42 @@ def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
trigger=trigger,
args=[str(job_info.id), code_block, *job_args],
kwargs=job_kwargs,
id=str(job_info.id),
id=job_id,
name=job_info.name,
coalesce=job_info.coalesce,
max_instances=1,
jobstore=jobstore,
executor=executor,
)
logger.info(f"任务 {job_info.id} 添加到 {jobstore} 存储器成功")
logger.info(f"任务 {job_id} 添加到 {jobstore} 存储器成功")
return job
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(
func=SchedulerUtil._task_wrapper,
trigger=trigger,
args=[str(job_info.id), code_block, *job_args],
kwargs=job_kwargs,
id=str(job_info.id),
id=job_id,
name=job_info.name,
coalesce=job_info.coalesce,
max_instances=1,
jobstore=jobstore,
executor=executor,
)
logger.info(f"任务 {job_info.id} 已存在,已移除旧任务并重新添加")
logger.info(f"任务 {job_id} 已存在,已移除旧任务并重新添加")
return 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))
return _add_job_with_trigger(job_info, trigger)
return _add_job_with_trigger(job_info, trigger, job_id=temp_id)
def add_cron_job(
-1
View File
@@ -48,7 +48,6 @@ class RedisInitKeyConfig(Enum):
EMAIL_CODES = {"key": "email_codes", "remark": "邮箱验证码"}
SYSTEM_CONFIG = {"key": "system_config", "remark": "系统配置"}
SYSTEM_DICT = {"key": "system_dict", "remark": "数据字典"}
APSCHEDULER_LOCK_KEY = {"key": "scheduler_job_lock", "remark": "定时任务初始化锁"}
AI_MODEL_CONFIG = {"key": "ai_model_config", "remark": "用户AI模型配置"}
@property
+5 -16
View File
@@ -18,13 +18,11 @@ from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor
from apscheduler.job import Job
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.jobstores.redis import RedisJobStore
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from redis.asyncio import Redis
from sqlalchemy.orm import Session
from app.config.setting import settings
@@ -37,15 +35,9 @@ JOB_STATUS_FAILED = 3
scheduler = AsyncIOScheduler()
scheduler.configure(
jobstores={
"default": RedisJobStore(
host=settings.REDIS_HOST,
port=int(settings.REDIS_PORT),
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),
# 默认分组 = PostgreSQLapscheduler_jobs 表):任务持久化,随 pg_data 每日备份;
# 与业务主库同生共死,不再依赖可降级/无备份的 Redis(多 Worker 场景本系统不适用,见运营手册)
"default": SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
"memory": MemoryJobStore(),
},
executors={
@@ -64,7 +56,6 @@ scheduler.configure(
class SchedulerUtil:
"""定时任务 SDK — 仅封装 APScheduler 核心操作,不含业务逻辑(无 ORM/实体引用)。"""
redis_instance: Redis | None = None
job_name_cache: dict[str, str | tuple[str, str]] = {}
@classmethod
@@ -129,15 +120,13 @@ class SchedulerUtil:
logger.info("所有任务已从调度器中移除")
@classmethod
async def init_scheduler(cls, redis: Redis | None = None) -> None:
async def init_scheduler(cls) -> None:
"""应用启动时初始化定时任务调度器(含系统级周期任务注册)。
返回:
- None
"""
try:
if redis:
cls.redis_instance = redis
scheduler.start()
scheduler.add_listener(cls._dispatch_job_event, EVENT_ALL)
scheduler.resume()
@@ -177,7 +166,7 @@ class SchedulerUtil:
@classmethod
def shutdown(cls, wait: bool = False) -> None:
# Redis 不可达时 start()/add_job 可能失败导致调度器未真正运行:
# jobstore 已落 PG:数据库不可达时 start()/add_job 可能失败导致调度器未真正运行:
# 对未运行实例调用 shutdown 会抛 SchedulerNotRunningError,须先判状态
if not scheduler.running:
return
+3 -3
View File
@@ -40,7 +40,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
else:
logger.warning(
"⚠️ Redis 不可用,以降级模式启动:参数/字典读接口回源数据库;"
"登录会话/在线监控/AI配置/调度任务接口将返回 503"
"登录会话/在线监控/AI配置接口将返回 503(调度任务 jobstore 已落 PG,不受影响)"
)
from app.utils.dict_util import preload_value2label_cache
@@ -50,11 +50,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
scheduler_ready = False
try:
await SchedulerUtil.init_scheduler(redis=app.state.redis)
await SchedulerUtil.init_scheduler()
scheduler_ready = SchedulerUtil.is_running()
logger.info("✅ 定时任务调度器初始化完成")
except Exception as e:
# Redis 不可达时默认 jobstore 连不上:不阻断启动,任务接口降级为 503
# 调度器初始化失败(jobstore 已落 PG,与主库同生共死):不阻断启动
SchedulerUtil.shutdown(wait=False)
logger.error("❌ 定时任务调度器初始化失败(继续启动,任务接口将返回 503): {}", e)