feat: improve schedule cleanup and handover

This commit is contained in:
Winnie
2026-08-20 19:12:44 +08:00
parent ae3b6d63e2
commit bc62034241
7 changed files with 306 additions and 63 deletions
+73 -1
View File
@@ -6,6 +6,78 @@
--- ---
## 0. 当前交接状态(2026-08-20
### 0.1 代码与服务
- 基线代码:`ae3b6d6``develop`)。本次改动尚未提交,修改范围见下方「0.4」。
- Docker Compose 项目名:`model-platform-develop14`
- 主入口:<http://127.0.0.1:9120>;浏览器通过 web/Nginx 访问后端 API。
- 当前启用的 `.env` 已配置 MySQL 连接和存储后端。**不要把 `.env` 中的连接串、密码或
Token 提交到 Git。**
检查服务:
```powershell
docker compose -p model-platform-develop14 ps
Invoke-WebRequest http://127.0.0.1:9120/api/v1/health
```
完整重建并启动:
```powershell
docker compose -p model-platform-develop14 up -d --build
```
仅后端 Python 代码变动时,使用下面命令即可;它不会删除数据库或 `data/`
```powershell
docker compose -p model-platform-develop14 build backend
docker compose -p model-platform-develop14 up -d --force-recreate --no-deps backend
```
### 0.2 调度模块本次行为
调度页面位于 `frontend/app/features/schedules/`,对应 API 位于
`backend/src/backend/schedules.py`
- 删除调度方案:删除该方案的节点、连线、运行记录,以及可删除的运行日志/结果产物;若方案
仍有运行中的任务,后端返回 409,避免删到一半。
- 删除节点:运行中的节点不可删除;有历史运行记录时,前端会提示“该节点有运行日志,是否一并删除?”。
用户确认后会删除节点、关联连线、该节点的 `schedule_node_runs` 记录和可删除日志/结果。
- `StorageObjects.is_immutable=1` 的审计原件受存储层保护,不能物理删除;删除节点时会删除其
运行记录引用,使其不再在调度页面展示,但保留原件,避免再次出现
`immutable object cannot be deleted` 并导致节点删除回滚。
- 运行记录列表已加入轮询:有运行中任务时约 1.5 秒刷新一次,空闲时约 5 秒刷新一次;右侧刷新
按钮仍可手动刷新。
### 0.3 关键排查位置
| 现象 | 首先查看 |
|---|---|
| 页面请求报错 | 浏览器 F12 → Network → Fetch/XHR,查看请求 URL、状态码和响应内容 |
| API / 数据库异常 | `docker compose -p model-platform-develop14 logs --tail=200 backend` |
| Cron 未触发 | `docker compose -p model-platform-develop14 logs --tail=200 schedule` |
| 脚本执行失败 | 调度页面“运行记录”→“查看结果”→ 节点日志 |
| 容器状态或端口问题 | `docker compose -p model-platform-develop14 ps` |
### 0.4 未提交改动清单
当前工作区包含以下代码改动,提交前应按需执行 typecheck / 后端测试:
| 文件 | 作用 |
|---|---|
| `backend/src/backend/schedule_schemas.py` | 节点删除请求支持历史记录确认参数 |
| `backend/src/backend/schedules.py` | 调度/节点删除、运行中保护、日志清理与不可变产物兼容 |
| `frontend/app/services/api.ts` | 节点删除 API 参数 |
| `frontend/app/context/AuthContext.tsx` | API 参数透传 |
| `frontend/app/features/schedules/state/schedulesStore.ts` | 删除确认及删除后刷新状态 |
| `frontend/app/features/schedules/SchedulePage.tsx` | 运行记录轮询 |
`data/` 是本地运行数据,不应作为本次代码改动一起提交。
---
## 1. 最近 8 个 commit(按时间倒序) ## 1. 最近 8 个 commit(按时间倒序)
### `85b2916` — fix: local_storage_base_dirbucket 标识符语义统一:最终 fix) ### `85b2916` — fix: local_storage_base_dirbucket 标识符语义统一:最终 fix)
@@ -423,4 +495,4 @@ M frontend/vite.config.ts (4+/2-)
``` ```
`frontend/vite.config.ts` 的改动不在本次 trash / bucket 修复范围内,等下次 commit 一起提。 `frontend/vite.config.ts` 的改动不在本次 trash / bucket 修复范围内,等下次 commit 一起提。
`data/` 是 `STORAGE_BACKEND=local` 的对象存储本地根,git ignore 不应跟踪。 `data/` 是 `STORAGE_BACKEND=local` 的对象存储本地根,git ignore 不应跟踪。
+6
View File
@@ -97,6 +97,12 @@ class WorkflowVersionRequest(StrictModel):
workflow_version: int = Field(ge=1) workflow_version: int = Field(ge=1)
class DeleteScheduleNodeRequest(WorkflowVersionRequest):
"""删除节点时可显式确认一并清理其已经结束的运行记录。"""
delete_execution_history: bool = False
class CreateScheduleNodeRequest(StrictModel): class CreateScheduleNodeRequest(StrictModel):
workflow_version: int = Field(ge=1) workflow_version: int = Field(ge=1)
node_key: str = Field( node_key: str = Field(
+145 -8
View File
@@ -17,13 +17,15 @@ from common.db.models import (
ScheduleEdges, ScheduleEdges,
ScheduleNodeRuns, ScheduleNodeRuns,
ScheduleNodes, ScheduleNodes,
ScheduleRuns,
Schedules, Schedules,
Scripts, Scripts,
StorageObjects,
Versions, Versions,
) )
from common.ids import new_ulid from common.ids import new_ulid
from croniter import CroniterBadCronError, croniter from croniter import CroniterBadCronError, croniter
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import delete, func, or_, select from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,14 +39,18 @@ from backend.schedule_schemas import (
CreateScheduleNodeRequest, CreateScheduleNodeRequest,
CreateScheduleRequest, CreateScheduleRequest,
CronPreviewRequest, CronPreviewRequest,
DeleteScheduleNodeRequest,
UpdateScheduleEdgeRequest, UpdateScheduleEdgeRequest,
UpdateScheduleNodeRequest, UpdateScheduleNodeRequest,
UpdateScheduleRequest, UpdateScheduleRequest,
WorkflowVersionRequest, WorkflowVersionRequest,
) )
from backend.services.storage import soft_delete_object
router = APIRouter(tags=["schedules"]) router = APIRouter(tags=["schedules"])
_ACTIVE_RUN_STATUSES = ("queued", "running")
def _iso(value: datetime | None) -> str | None: def _iso(value: datetime | None) -> str | None:
if value is None: if value is None:
@@ -60,6 +66,48 @@ def _mysql_utc(value: datetime) -> datetime:
return value.astimezone(UTC).replace(tzinfo=None) return value.astimezone(UTC).replace(tzinfo=None)
def _execution_artifact_ids(
items: list[ScheduleRuns | ScheduleNodeRuns],
) -> set[str]:
"""收集运行日志和结果产物,供删除记录时一并移入回收站。"""
return {
storage_object_id
for item in items
for storage_object_id in (item.logs_object_id, item.result_object_id)
if storage_object_id
}
async def _delete_execution_artifacts(
storage_object_ids: set[str],
request: Request,
session: AsyncSession,
) -> None:
"""软删除可删除的运行产物。
不可变对象是运行审计原件,存储层不允许移动或删除它们。调用方随后会
删除运行记录本身,因此不可变原件不会再通过该调度节点暴露;保留原件也
不应阻塞节点或调度方案的删除。
"""
if not storage_object_ids:
return
mutable_storage_object_ids = set(
(
await session.scalars(
select(StorageObjects.storage_object_id).where(
StorageObjects.storage_object_id.in_(storage_object_ids),
StorageObjects.is_immutable == 0,
)
)
).all()
)
for storage_object_id in sorted(mutable_storage_object_ids):
await soft_delete_object(storage_object_id, request, session)
def _timezone(value: str) -> ZoneInfo: def _timezone(value: str) -> ZoneInfo:
try: try:
return ZoneInfo(value) return ZoneInfo(value)
@@ -740,6 +788,7 @@ async def update_schedule(
async def delete_schedule( async def delete_schedule(
schedule_id: str, schedule_id: str,
payload: WorkflowVersionRequest, payload: WorkflowVersionRequest,
request: Request,
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -750,6 +799,64 @@ async def delete_schedule(
for_update=True, for_update=True,
) )
require_workflow_version(item, payload.workflow_version) require_workflow_version(item, payload.workflow_version)
# 正在执行的 DAG 依赖运行快照和日志对象。此时删除会让执行器无法安全收尾,
# 因此要求先等待运行结束,避免影响现有运行功能。
active_run_id = await session.scalar(
select(ScheduleRuns.run_id)
.where(
ScheduleRuns.schedule_id == schedule_id,
ScheduleRuns.run_status.in_(_ACTIVE_RUN_STATUSES),
)
.limit(1)
)
if active_run_id is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"schedule has active runs; wait for completion before deletion",
)
schedule_runs = list(
(
await session.scalars(
select(ScheduleRuns).where(ScheduleRuns.schedule_id == schedule_id)
)
).all()
)
run_ids = [run.run_id for run in schedule_runs]
node_runs = (
list(
(
await session.scalars(
select(ScheduleNodeRuns).where(
ScheduleNodeRuns.run_id.in_(run_ids)
)
)
).all()
)
if run_ids
else []
)
# 调度删除会清理节点、边和所有运行记录;运行日志/结果文件同时移入回收站。
await _delete_execution_artifacts(
_execution_artifact_ids([*schedule_runs, *node_runs]),
request,
session,
)
if run_ids:
await session.execute(
delete(ScheduleNodeRuns).where(ScheduleNodeRuns.run_id.in_(run_ids))
)
await session.execute(
delete(ScheduleRuns).where(ScheduleRuns.run_id.in_(run_ids))
)
await session.execute(
delete(ScheduleEdges).where(ScheduleEdges.schedule_id == schedule_id)
)
await session.execute(
delete(ScheduleNodes).where(ScheduleNodes.schedule_id == schedule_id)
)
item.enabled = 0 item.enabled = 0
item.next_run_at = None item.next_run_at = None
item.deleted_at = _mysql_utc(datetime.now(UTC)) item.deleted_at = _mysql_utc(datetime.now(UTC))
@@ -884,7 +991,8 @@ async def update_schedule_node(
async def delete_schedule_node( async def delete_schedule_node(
schedule_id: str, schedule_id: str,
node_id: str, node_id: str,
payload: WorkflowVersionRequest, payload: DeleteScheduleNodeRequest,
request: Request,
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -903,15 +1011,44 @@ async def delete_schedule_node(
) )
if node is None: if node is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node not found")
has_runs = await session.scalar( active_run_id = await session.scalar(
select(func.count()) select(ScheduleRuns.run_id)
.select_from(ScheduleNodeRuns) .join(ScheduleNodeRuns, ScheduleNodeRuns.run_id == ScheduleRuns.run_id)
.where(ScheduleNodeRuns.node_id == node_id) .where(
ScheduleRuns.schedule_id == schedule_id,
ScheduleRuns.run_status.in_(_ACTIVE_RUN_STATUSES),
ScheduleNodeRuns.node_id == node_id,
)
.limit(1)
) )
if int(has_runs or 0): if active_run_id is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"a node with execution history cannot be deleted", "node has active execution; wait for the run to finish before deletion",
)
node_runs = list(
(
await session.scalars(
select(ScheduleNodeRuns).where(ScheduleNodeRuns.node_id == node_id)
)
).all()
)
if node_runs and not payload.delete_execution_history:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "node_execution_history_exists",
"message": "node has execution history; confirmation required",
},
)
if node_runs:
await _delete_execution_artifacts(
_execution_artifact_ids(node_runs),
request,
session,
)
await session.execute(
delete(ScheduleNodeRuns).where(ScheduleNodeRuns.node_id == node_id)
) )
await session.execute( await session.execute(
delete(ScheduleEdges).where( delete(ScheduleEdges).where(
+8 -2
View File
@@ -279,8 +279,14 @@ export function useApi(): WorkspaceBoundApi {
rawApi.createScheduleNode(workspaceId, scheduleId, input), rawApi.createScheduleNode(workspaceId, scheduleId, input),
updateScheduleNode: (scheduleId, nodeId, input) => updateScheduleNode: (scheduleId, nodeId, input) =>
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input), rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
deleteScheduleNode: (scheduleId, nodeId, workflowVersion) => deleteScheduleNode: (scheduleId, nodeId, workflowVersion, options) =>
rawApi.deleteScheduleNode(workspaceId, scheduleId, nodeId, workflowVersion), rawApi.deleteScheduleNode(
workspaceId,
scheduleId,
nodeId,
workflowVersion,
options,
),
createScheduleEdge: (scheduleId, input) => createScheduleEdge: (scheduleId, input) =>
rawApi.createScheduleEdge(workspaceId, scheduleId, input), rawApi.createScheduleEdge(workspaceId, scheduleId, input),
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) => deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
@@ -234,9 +234,6 @@ export default function SchedulePage({
void useSchedulesStore.getState().loadInitial(); void useSchedulesStore.getState().loadInitial();
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]); }, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
// 进入调度页或切换调度方案后,主动加载该方案已经存在的运行记录。
// 原先只有「手动运行」和运行中的轮询会更新列表,因此从其他页面返回时会
// 一直停留在加载状态,直到产生新的运行记录。
useEffect(() => { useEffect(() => {
const scheduleId = schedule?.schedule_id; const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) { if (!scheduleId || !api) {
@@ -244,35 +241,32 @@ export default function SchedulePage({
setRunsLoading(false); setRunsLoading(false);
return; return;
} }
void useSchedulesStore.getState().refreshRuns(scheduleId, true);
}, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) return;
let timer: number | undefined; let timer: number | undefined;
let cancelled = false; let cancelled = false;
const tick = (): void => { const tick = async (showLoading = false): Promise<void> => {
if (cancelled) return; if (cancelled) return;
void useSchedulesStore.getState().refreshRuns(scheduleId); // 必须等待请求完成再判断状态;旧实现会在首个请求返回前看到空数组,
// 每次 tick 重新读最新 runs,不再把 runs 放进依赖数组,避免每次 setRuns // 因而错误地停止轮询,导致运行记录必须手工刷新才出现。
// 都触发 effect cleanup + 重建 setInterval。 await useSchedulesStore.getState().refreshRuns(scheduleId, showLoading);
if (cancelled || useSchedulesStore.getState().schedule?.schedule_id !== scheduleId) {
return;
}
const runs = useSchedulesStore.getState().runs; const runs = useSchedulesStore.getState().runs;
const hasActiveRun = runs.some( const hasActiveRun = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running", (item) => item.run_status === "queued" || item.run_status === "running",
); );
const isEnabledCron = schedule.trigger_type === "cron" && schedule.enabled; // 页面停留期间持续刷新:运行中更快,空闲时较慢,既能自动显示 Cron
// Cron 会由后端在未来某个整分钟创建新记录。即使当前没有运行中的 // 新记录,也不会因频繁请求影响其他调度页面操作。
// 记录,也要持续刷新,才能让新一轮运行自动出现在右侧列表中。 timer = window.setTimeout(() => {
if (!hasActiveRun && !isEnabledCron) return; void tick();
timer = window.setTimeout(tick, hasActiveRun ? 1500 : 3000); }, hasActiveRun ? 1500 : 5000);
}; };
tick(); void tick(true);
return () => { return () => {
cancelled = true; cancelled = true;
if (timer !== undefined) window.clearTimeout(timer); if (timer !== undefined) window.clearTimeout(timer);
}; };
}, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, api]); }, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX. // Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => { const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
@@ -368,6 +368,30 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
}); });
} }
function applyServerUpdatedSchedule(serverUpdated: Schedule): void {
const validNodeIds = new Set(
serverUpdated.nodes.map((node) => node.node_id),
);
for (const nodeId of [...positionDrafts.keys()]) {
if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId);
}
const updated = applyPositionDrafts(serverUpdated);
set((state) => ({
schedule: updated,
schedules: (() => {
const summary = { ...updated, nodes: [], edges: [] };
const index = state.schedules.findIndex(
(item) => item.schedule_id === updated.schedule_id,
);
if (index < 0) return [summary, ...state.schedules];
return state.schedules.map((item) =>
item.schedule_id === updated.schedule_id ? summary : item
);
})(),
positionDraftCount: positionDrafts.size,
}));
}
// Generic mutation wrapper: busy + try/catch + 412 + positionDraft cleanup + schedules list update // Generic mutation wrapper: busy + try/catch + 412 + positionDraft cleanup + schedules list update
async function withMutation( async function withMutation(
label: string, label: string,
@@ -379,29 +403,9 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
set({ busy: label }); set({ busy: label });
try { try {
const serverUpdated = await action(); const serverUpdated = await action();
const validNodeIds = new Set( applyServerUpdatedSchedule(serverUpdated);
serverUpdated.nodes.map((node) => node.node_id),
);
for (const nodeId of [...positionDrafts.keys()]) {
if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId);
}
const updated = applyPositionDrafts(serverUpdated);
set((state) => ({
schedule: updated,
schedules: (() => {
const summary = { ...updated, nodes: [], edges: [] };
const index = state.schedules.findIndex(
(item) => item.schedule_id === updated.schedule_id,
);
if (index < 0) return [summary, ...state.schedules];
return state.schedules.map((item) =>
item.schedule_id === updated.schedule_id ? summary : item
);
})(),
positionDraftCount: positionDrafts.size,
}));
notify({ tone: "success", message: successMessage }); notify({ tone: "success", message: successMessage });
return updated; return serverUpdated;
} catch (error) { } catch (error) {
await handleError(error, `${successMessage}失败`); await handleError(error, `${successMessage}失败`);
return null; return null;
@@ -946,18 +950,37 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
if (!schedule || !node || state.busy) return; if (!schedule || !node || state.busy) return;
set({ contextMenu: null }); set({ contextMenu: null });
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return; if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
const updated = await withMutation( set({ busy: "delete-node" });
"delete-node", try {
() => let updated: Schedule;
api.deleteScheduleNode( try {
// 先走普通删除:没有历史记录时不额外打扰用户。
updated = await api.deleteScheduleNode(
schedule.schedule_id, schedule.schedule_id,
node.node_id, node.node_id,
schedule.workflow_version, schedule.workflow_version,
), );
"节点已删除", } catch (error) {
); const requiresHistoryConfirmation =
if (updated && selectedNodeId === node.node_id) { error instanceof ApiRequestError
set({ selectedNodeId: null }); && error.status === 409
&& error.code === "node_execution_history_exists";
if (!requiresHistoryConfirmation) throw error;
if (!window.confirm("该节点有运行日志,是否一并删除?")) return;
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
{ delete_execution_history: true },
);
}
applyServerUpdatedSchedule(updated);
if (selectedNodeId === node.node_id) set({ selectedNodeId: null });
notify({ tone: "success", message: "节点及其运行日志已删除" });
} catch (error) {
await handleError(error, "删除节点失败");
} finally {
set({ busy: null });
} }
}, },
+6 -1
View File
@@ -1314,10 +1314,14 @@ export async function deleteScheduleNode(
scheduleId: string, scheduleId: string,
nodeId: string, nodeId: string,
workflowVersion: number, workflowVersion: number,
options: { delete_execution_history?: boolean } = {},
): Promise<Schedule> { ): Promise<Schedule> {
return apiRequest<Schedule>( return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`, `/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) }, {
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion, ...options }),
},
workspaceId, workspaceId,
); );
} }
@@ -1586,6 +1590,7 @@ export type WorkspaceBoundApi = {
scheduleId: string, scheduleId: string,
nodeId: string, nodeId: string,
workflowVersion: number, workflowVersion: number,
options?: { delete_execution_history?: boolean },
) => Promise<Schedule>; ) => Promise<Schedule>;
createScheduleEdge: ( createScheduleEdge: (
scheduleId: string, scheduleId: string,