Merge branch 'refactor/remove-redis' into develop

# Conflicts:
#	.env.example
#	CLAUDE.md
#	backend/Dockerfile
#	backend/pyproject.toml
#	backend/src/backend/main.py
#	backend/src/backend/schedule_runs.py
#	common/src/common/db/models.py
#	common/src/common/eventing.py
#	contracts/README.md
#	contracts/demo-core-v1.md
#	contracts/events/README.md
#	contracts/events/event-envelope-v1.json
#	contracts/locks/README.md
#	contracts/locks/file-edit-lock-v1.md
#	contracts/schedules/schedule-definition-api-v1.md
#	docker-compose.yml
#	frontend/README.md
#	migrations/README.md
#	migrations/versions/20260724_0001_v1_schema_baseline.py
#	runtime/Dockerfile
#	runtime/README.md
#	runtime/pyproject.toml
#	runtime/src/runtime/main.py
#	schedule/Dockerfile
#	schedule/README.md
#	schedule/pyproject.toml
#	schedule/src/schedule/main.py
#	schedule/src/schedule/service.py
This commit is contained in:
tao.chen
2026-07-30 20:14:53 +08:00
65 changed files with 12108 additions and 659 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
**/__pycache__
**/*.py[cod]
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.venv
frontend/node_modules
frontend/build
frontend/.react-router
deploy/data
*.zip
+28
View File
@@ -0,0 +1,28 @@
COMPOSE_PROJECT_NAME=model-develop-new
# Local development ports
GATEWAY_PORT=28081
MYSQL_PORT=23318
RUSTFS_API_PORT=29010
RUSTFS_CONSOLE_PORT=29011
BACKEND_PORT=28010
RUNTIME_PORT=28012
SCHEDULE_PORT=28013
# Jupyter
JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31
JUPYTER_TOKEN=ModelDevelop_Jupyter_Isolated_2026_A7K9
# MySQL
MYSQL_DATABASE=model_develop_isolated
MYSQL_USER=model_develop_app
MYSQL_PASSWORD=ModelDevelop_MySQL_App_2026_B8M4
MYSQL_ROOT_PASSWORD=ModelDevelop_MySQL_Root_2026_C6R2
# RustFS
RUSTFS_IMAGE=rustfs/rustfs:latest
RUSTFS_ACCESS_KEY=modeldevelopisolated
RUSTFS_SECRET_KEY=ModelDevelop_RustFS_2026_D9P5
# Internal service authentication
INTERNAL_SERVICE_TOKEN=ModelDevelop_Internal_Service_2026_E4T8
+1 -6
View File
@@ -1,17 +1,15 @@
COMPOSE_PROJECT_NAME=model-platform-refactored
# Local development ports
NGINX_PORT=8080
GATEWAY_PORT=8081
MYSQL_PORT=3308
REDIS_PORT=6380
RUSTFS_API_PORT=9010
RUSTFS_CONSOLE_PORT=9011
BACKEND_PORT=8010
RUNTIME_PORT=8012
SCHEDULE_PORT=8013
# Jupyter runs on the internal Compose network only in step 14.
# Jupyter only exposes port 8888 inside the Compose network.
JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31
JUPYTER_TOKEN=ChangeMe_Jupyter_Internal_2026
@@ -21,9 +19,6 @@ MYSQL_USER=model_platform
MYSQL_PASSWORD=ChangeMe_MySQL_App_2026
MYSQL_ROOT_PASSWORD=ChangeMe_MySQL_Root_2026
# Redis local development credential
REDIS_PASSWORD=ChangeMe_Redis_2026
# RustFS local development image and credentials
RUSTFS_IMAGE=rustfs/rustfs:latest
RUSTFS_ACCESS_KEY=modelplatform
+39
View File
@@ -0,0 +1,39 @@
# 简化系统架构
```text
Browser
|
v
Nginx Gateway (静态 React Router SPA + /api + /jupyter 代理)
|-----------------------------|
| /api/v1 | /jupyter/
v v
FastAPI Backend Shared Jupyter Server
| ^
| Runtime HTTP | Runtime 管理会话/票据
v |
Runtime Manager ---------------|
|
+------ MySQL(编辑租约、运行实例)
FastAPI Backend
| 1. 写 schedule_runs + outbox_events
| 2. 尝试 HTTP 立即推送
v
Schedule ExecutorAPScheduler
|-- MySQL APSchedulerJobStore
|-- MySQL Outbox 轮询兜底
|-- DAG 节点执行与重试
|-- RustFS 日志/结果
+-- Backend 内部 Storage API
```
## 关键简化
1. 删除 Redis 服务、Redis Streams 和 Redis 文件锁。
2. 调度定义、运行记录、Outbox、Inbox、Cron JobStore 都由 MySQL 保存。
3. 立即运行采用 Backend -> Schedule Executor 内部 HTTP 推送;推送失败由 MySQL Outbox 轮询兜底。
4. Schedule Executor 自带 APScheduler,负责 Cron 触发和 DAG 执行。
5. 文件编辑锁改为 MySQL 租约,Runtime 单副本运行。
6. Jupyter 使用一个共享容器,工作区目录通过 Volume 挂载同步。
7. 前端改为 React Router SPA,并按 feature / route / service / component 分层。
+44 -58
View File
@@ -1,78 +1,64 @@
# CLAUDE.md
# Repository Guide
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Current architecture
## Repository overview
- `frontend`: React Router SPA. Production files are built in `nginx/Dockerfile`.
- `backend`: public FastAPI API and internal RustFS storage API in one process.
- `runtime`: shared Jupyter lifecycle, MySQL edit leases and short-lived in-memory access tickets.
- `schedule`: APScheduler, MySQL JobStore, MySQL Outbox polling and DAG execution.
- `common`: SQLAlchemy models, database/session helpers, IDs and object-store helpers.
- `migrations`: Alembic schema and seed migrations.
- `nginx`: static frontend, `/api/` proxy and authenticated `/jupyter/` proxy.
This is a Python 3.12 `uv` workspace for a small Jupyter workspace platform. The root `pyproject.toml` includes four workspace packages:
Redis and the former separate Storage API container are intentionally removed.
- `backend`: FastAPI service used by Nginx `auth_request`. It reads the original workspace/URI headers, performs the current authentication and notebook-lock checks, asks Runtime to find or start a workspace, and returns the selected Jupyter upstream and internal token in response headers for Nginx.
- `runtime`: FastAPI process manager. At startup it mounts `REMOTE_BUCKET` with `rclone`, scans `WORKSPACES_ROOT`, and auto-starts a Jupyter Notebook process for each workspace directory. It keeps process metadata (PID, port, token, URL, start time) in the in-memory `JUPYTER_PROCESSES` dictionary and exposes health and start/stop/list/get actions.
- `common`: Shared package scaffold for SQLAlchemy/Alembic database code and object-storage helpers. `common/src/common/migrations/env.py` uses `common.db.base.Base.metadata`; the model/session/storage layers are currently mostly placeholders.
- `schedule`: Package scaffold only; `main.py` and `executor.py` are currently empty apart from module headers.
## Commands
The request path in the deployed stack is **client → Nginx (`web`) → backend auth subrequest → runtime → per-workspace Jupyter**. `default.conf` also proxies `/storage/` to RustFS and configures WebSocket forwarding for Jupyter. The frontend is not implemented in this repository; `frontend/README.md` only identifies it as the frontend.
## Common commands
Run these from the repository root. Dependencies are managed by the committed `uv.lock` file.
From the repository root:
```bash
# Install/synchronize all workspace dependencies
uv sync
# Local Python workspace
uv sync --all-packages
# Run the services locally
make runtime # Runtime on 0.0.0.0:8001
make runtime-dev # Runtime with Uvicorn reload
make backend # Backend on 0.0.0.0:8000
make backend-dev # Backend with Uvicorn reload
# Static Python check
python -m compileall common/src backend/src runtime/src schedule/src
# Equivalent direct commands
uv run --package runtime uvicorn runtime.main:app --host 0.0.0.0 --port 8001
uv run --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8000
# Database migration
uv run --package backend alembic upgrade head
# Run the containerized stack (Nginx is exposed on localhost:8888)
docker compose up --build
docker compose down
# Full Docker stack
cp .env.example .env
docker compose config
docker compose up -d --build
```
`Makefile` variables can be overridden on the command line, for example:
Frontend development:
```bash
make runtime-dev WORKSPACES_ROOT=./test/workspaces RUNTIME_PORT=8001
make backend-dev RUNTIME_BASE_URL=http://127.0.0.1:8001
cd frontend
pnpm install
pnpm dev
pnpm typecheck
pnpm build
```
The local Runtime defaults to `WORKSPACES_ROOT=/app/workspaces` in code, while the Makefile overrides it to `./test/workspaces`; make sure the directory exists and contains workspace directories before expecting auto-start behavior. Runtime also needs `rclone`, FUSE support, and a valid remote configuration when exercising the mount path. The Docker image provides `rclone` and `fuse3`; a local process does not.
## Service rules
### Tests, linting, and formatting
- Browser traffic enters through Gateway only.
- Frontend API calls use same-origin `/api/v1/...` paths.
- Backend writes `schedule_runs` and `outbox_events`, then performs best-effort HTTP dispatch to Schedule Executor.
- Schedule Executor always polls pending MySQL Outbox rows, so HTTP dispatch failure does not lose a task.
- Cron jobs are persisted by APScheduler in MySQL table `apscheduler_jobs`.
- Runtime must stay single-replica while file leases and Jupyter tickets use the simplified implementation.
- Never expose the internal Jupyter token to the browser.
- Never delete Docker volumes when preserving MySQL or RustFS data is required.
There is currently no test suite, test configuration, or lint/format command in the repository. Do not assume `make test`, `pytest`, Ruff, or Black is configured. If tests are added, run a single test with the projects chosen runner (the conventional pytest form is `uv run pytest path/to/test_file.py::test_name`), then add the corresponding dependency and documented command rather than silently relying on a globally installed tool.
## Main entrypoints
A lightweight current smoke check is to compile the Python sources:
```bash
python -m compileall backend/src common/src runtime/src schedule/src
```text
frontend/app/routes/platform.tsx
backend/src/backend/main.py
runtime/src/runtime/main.py
schedule/src/schedule/main.py
nginx/default.conf.template
```
### Database migrations
Alembic is configured in `common/alembic.ini`, with scripts under `common/src/common/migrations`. The checked-in `sqlalchemy.url` is the generated placeholder `driver://user:pass@localhost/dbname`, so replace/configure it for a real database before running migrations. From the root, the usual commands are:
```bash
uv run alembic -c common/alembic.ini current
uv run alembic -c common/alembic.ini upgrade head
uv run alembic -c common/alembic.ini revision --autogenerate -m "describe change"
```
## Important implementation details
- `backend/src/backend/main.py` is the ASGI entrypoint (`backend.main:app`). Nginx sends `/internal-auth` to `GET /api/v1/auth/jupyter`; the backend expects `X-Original-Workspace-Id` and `X-Original-URI`, chooses a bearer token or `access_token` cookie, and returns `x-upstream-addr` plus `x-jupyter-internal-token` headers. The JWT validation and database lock lookup are currently mocked/commented, so treat this as demo behavior rather than complete production authentication.
- `runtime/src/runtime/main.py` is the ASGI entrypoint (`runtime.main:app`). Startup/shutdown is implemented through FastAPI lifespan: mount/scan/start on startup, stop Jupyter processes and unmount on shutdown. The API is `GET /api/v1/health` and `POST /api/v1/jupyter` with `action` values `start`, `stop`, `list`, or `get` (workspace ID is required for all except `list`).
- Runtime workspace IDs are directory names below `WORKSPACES_ROOT`. A started Jupyter server receives a dynamic port, a generated token, and base URL `/jupyter/<workspace_id>/`. Because process state is only in memory, restarting Runtime loses the registry and causes startup scanning to rediscover workspaces.
- `common` is a uv workspace dependency declared by backend/runtime/schedule, but the current service code does not yet contain substantial shared model or storage integration. Keep shared DB/storage behavior in `common` rather than duplicating it in services.
- `docker-compose.yml` builds backend and runtime from the root workspace, exposes backend on `8000`, runtime on `8001`, and Nginx on `8888`. Runtime requires Linux FUSE capabilities (`SYS_ADMIN`, `/dev/fuse`, and unconfined AppArmor). Its RustFS/rclone settings are supplied as compose environment variables; use deployment secrets or environment overrides instead of committing credentials.
## Change boundaries
Keep service entrypoints and the Nginx header contract compatible when changing backend/runtime behavior. If an API action or response field changes, update both the caller in `backend` and the proxy rules in `default.conf` together. For runtime process changes, preserve cleanup in the lifespan shutdown path and consider failures from missing mounts, dead subprocesses, dynamic port allocation, and remote storage separately.
+69
View File
@@ -0,0 +1,69 @@
# Model Platform Refactored
本项目是简化后的模型实验开发平台:React Router 前端、FastAPI Backend、
Runtime、Schedule Executor、MySQL、RustFS、共享 Jupyter 与 Nginx Gateway。
Redis 已移除。
## 目录
```text
frontend/app/
components/ 通用组件
features/platform/ 平台主框架与脚本工作台
features/schedules/ 调度画布
features/admin/ 工作台与系统管理
routes/ React Router 路由
services/ API 调用与 DTO
backend/ 核心业务 API
runtime/ Jupyter 与文件编辑租约
schedule/ APScheduler + DAG Executor
common/ 数据库模型和公共能力
nginx/ 前端静态站点与统一反向代理
```
## 简化后的调用链
```text
Browser -> Nginx Gateway -> FastAPI Backend -> MySQL / RustFS
-> Runtime -> shared Jupyter
Backend --HTTP push-----> Schedule Executor
Schedule Executor --poll MySQL Outbox / APScheduler--> execute DAG
```
## 启动
Windows PowerShell
```powershell
Copy-Item .env.example .env
docker compose config
docker compose up -d --build
docker compose ps
```
Git Bash/Linux
```bash
cp .env.example .env
docker compose config
docker compose up -d --build
docker compose ps
```
访问:`http://127.0.0.1:8081`
查看日志:
```bash
docker compose logs -f backend runtime schedule gateway
```
数据库迁移由 `migrate` 一次性容器在 Backend 启动前自动执行。
## Windows 快速脚本
```powershell
.\scripts\start.ps1
.\scripts\logs.ps1
.\scripts\stop.ps1
```
+56
View File
@@ -0,0 +1,56 @@
# 本次重构说明
## 已完成
### 前端
- 将原 `frontend/src` 页面迁入 React Router SPA 结构;
- 主入口改为 `app/routes/platform.tsx`
- 拆分为 `components / features / routes / services / styles`
- 保留脚本管理、目录管理、Jupyter 编辑、版本发布、调度画布、运行记录和系统管理功能;
- 将原 Hash 页面切换改为 React Router 路径:`/``/scripts``/schedules``/system`
- Nginx 使用 SPA fallback,刷新子路径不会 404。
### 后端
- 合并 Platform API 和 Storage API 到同一个 `backend` 进程;
- 删除 Redis 容器、依赖、Streams 消费和锁实现;
- 文件锁改为 MySQL `edit_sessions` 租约;
- Jupyter 短期票据由单 Runtime 进程内存保存;
- Schedule Executor 内置 APScheduler,并使用 MySQL `apscheduler_jobs`
- Backend 创建运行和 Outbox 后,通过内部 HTTP 尝试立即推送;
- Schedule Executor 轮询 MySQL Outbox 作为失败兜底;
- 增加迁移 `20260730_0004`,兼容旧数据库的 `redis_lock_key -> lock_key`
### Docker
最终容器:
```text
mysql
rustfs
jupyter
migrate(一次性)
backend
runtime
schedule
gateway
```
## 已执行检查
- Python 全项目 `compileall` 通过;
- SQLAlchemy 模型导入通过,共加载 26 张表;
- Alembic `upgrade head --sql` 离线生成通过;
- `docker-compose.yml` YAML 解析通过;
- 前端 feature、route 和 root 文件 TypeScript 静态检查通过。
## 未在当前沙箱执行
当前执行环境没有 Docker 命令,并且无法连接 npm/PyPI,因此没有在沙箱内完成:
- `docker compose up --build`
- 正式 `pnpm install && pnpm build`
- Python 依赖在线安装后的集成测试。
请在安装了 Docker Desktop且网络可访问依赖仓库的 Windows 电脑上执行根目录 README 中的启动命令。
+3 -4
View File
@@ -1,14 +1,13 @@
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
COPY pyproject.toml uv.lock ./
COPY common ./common
COPY backend ./backend
COPY alembic.ini ./
COPY migrations ./migrations
RUN uv sync --frozen --no-dev --no-editable --package backend
RUN uv pip install --system ./common ./backend
EXPOSE 8000
CMD ["uv", "run", "--frozen", "--package", "backend", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1 -1
View File
@@ -13,7 +13,7 @@ dependencies = [
]
[tool.uv.sources]
common = { workspace = true }
common = { path = "../common" }
[build-system]
requires = ["hatchling"]
+10
View File
@@ -18,6 +18,7 @@ from backend.jupyter import router as jupyter_router
from backend.resources import router as resources_router
from backend.runtime_client import RuntimeClient
from backend.schedule_runs import router as schedule_runs_router
from backend.schedule_client import ScheduleExecutorClient
from backend.schedules import router as schedules_router
from backend.scripts import router as scripts_router
from backend.storage_api import app as storage_app
@@ -72,10 +73,19 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
runtime_http_client,
os.environ["INTERNAL_SERVICE_TOKEN"],
)
schedule_http_client = httpx.AsyncClient(
base_url=os.getenv("SCHEDULE_API_URL", "http://schedule:8000"),
timeout=httpx.Timeout(10.0),
)
app.state.schedule_client = ScheduleExecutorClient(
schedule_http_client,
os.environ["INTERNAL_SERVICE_TOKEN"],
)
try:
yield
finally:
await runtime_http_client.aclose()
await schedule_http_client.aclose()
await storage_http_client.aclose()
await engine.dispose()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import logging
import httpx
LOGGER = logging.getLogger(__name__)
class ScheduleExecutorClient:
"""Best-effort HTTP notification for immediate run dispatch.
MySQL remains the source of truth. If this notification fails, the
executor's database polling loop will still pick up the pending Outbox row.
"""
def __init__(self, client: httpx.AsyncClient, service_token: str) -> None:
self.client = client
self.headers = {"X-Service-Token": service_token}
async def dispatch_run(self, run_id: str) -> bool:
try:
response = await self.client.post(
f"/internal/v1/runs/{run_id}/dispatch",
headers=self.headers,
)
except httpx.RequestError:
LOGGER.warning(
"schedule executor notification failed for run %s",
run_id,
exc_info=True,
)
return False
if response.is_error:
LOGGER.warning(
"schedule executor rejected run %s: %s %s",
run_id,
response.status_code,
response.text[:500],
)
return False
return True
+8 -3
View File
@@ -4,7 +4,7 @@ import hashlib
from datetime import UTC, datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from pydantic import Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -176,12 +176,13 @@ async def _visible_run(
)
async def run_schedule_now(
schedule_id: str,
request: Request,
payload: RunScheduleRequest | None = None,
idempotency_key: str = Header(alias="Idempotency-Key"),
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
del payload
reason = payload.reason if payload is not None else "manual_run"
key = _normalized_idempotency_key(
context.workspace.workspace_id,
schedule_id,
@@ -263,7 +264,7 @@ async def run_schedule_now(
schedule_id=schedule.schedule_id,
workspace_id=schedule.workspace_id,
workflow_version=schedule.workflow_version,
trigger_type="manual",
trigger_type="cron" if reason == "cron" else "manual",
idempotency_key=key,
run_status="queued",
state_version=0,
@@ -292,6 +293,10 @@ async def run_schedule_now(
},
)
await session.flush()
# Commit before the HTTP push so the executor can read the Outbox row.
# The executor also polls MySQL, so a failed push does not lose the run.
await session.commit()
await request.app.state.schedule_client.dispatch_run(run.run_id)
await session.refresh(run)
return {
"request_id": context.request_id,
+5 -5
View File
@@ -14,14 +14,14 @@ class ConsumerInbox(Base):
__tablename__ = 'consumer_inbox'
__table_args__ = (
Index('idx_consumer_inbox_status', 'consumer_name', 'process_status', 'created_at'),
{'comment': '消费者幂等 Inbox,防止 Stream 重投导致重复执行'}
{'comment': '消费者幂等 Inbox,防止数据库事件重复处理'}
)
consumer_name: Mapped[str] = mapped_column(String(128), primary_key=True)
event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
process_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'processing'"), comment='processing/succeeded/failed')
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
message_id: Mapped[Optional[str]] = mapped_column(String(128), comment='Redis Stream message ID')
message_id: Mapped[Optional[str]] = mapped_column(String(128), comment='数据库事件处理批次标识')
processed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
error_message: Mapped[Optional[str]] = mapped_column(String(2000))
@@ -32,7 +32,7 @@ class OutboxEvents(Base):
Index('idx_outbox_aggregate', 'aggregate_type', 'aggregate_id', 'created_at'),
Index('idx_outbox_idempotency', 'idempotency_key'),
Index('idx_outbox_pending', 'event_status', 'available_at', 'created_at'),
{'comment': '事务 Outbox提交后发布到 Redis Streams'}
{'comment': '事务 Outbox由 Schedule Executor 直接轮询处理'}
)
event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
@@ -432,14 +432,14 @@ class EditSessions(Base):
Index('idx_edit_sessions_object', 'storage_object_id', 'session_status', 'expires_at'),
Index('idx_edit_sessions_runtime', 'runtime_id', 'session_status'),
Index('idx_edit_sessions_user', 'user_id', 'session_status'),
{'comment': '编辑会话审计;实时锁状态以 Redis 为准'}
{'comment': '编辑会话与数据库租约;MySQL 为锁状态权威'}
)
edit_session_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
redis_lock_key: Mapped[str] = mapped_column(String(512), nullable=False)
lock_key: Mapped[str] = mapped_column(String(512), nullable=False)
lock_token_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False, comment='不保存原始 token')
session_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"), comment='active/closed/expired/failed')
started_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+5 -5
View File
@@ -9,10 +9,10 @@ from common.db.models import OutboxEvents
from common.ids import new_ulid
STREAM_BY_EVENT_TYPE = {
"schedule.run.requested": "stream:scheduler:commands",
"job.node.execute": "stream:jobs:execute",
"job.node.finished": "stream:jobs:results",
SUPPORTED_EVENT_TYPES = {
"schedule.run.requested",
"job.node.execute",
"job.node.finished",
}
@@ -39,7 +39,7 @@ async def add_outbox_event(
payload: dict[str, Any],
available_at: datetime | None = None,
) -> OutboxEvents:
if event_type not in STREAM_BY_EVENT_TYPE:
if event_type not in SUPPORTED_EVENT_TYPES:
raise ValueError(f"unsupported event type: {event_type}")
event_id = new_ulid()
envelope = {
+1 -1
View File
@@ -4,7 +4,7 @@
```text
openapi/ HTTP OpenAPI 3 契约
events/ Redis Streams JSON Schema
events/ MySQL Outbox 内部事件 JSON Schema
runtime/ Runtime Adapter 契约
locks/ Notebook 编辑锁契约
```
+8 -9
View File
@@ -30,17 +30,16 @@ Demo 公共 HTTP API 由两部分共同组成:
- 失败策略:`stop / continue`
- 触发类型:`manual / cron / api / retry`
## 4. Redis Streams 路由
## 4. MySQL Outbox 与 HTTP 推送
| Stream | Consumer Group | 事件 | 生产者 | 消费者 |
|---|---|---|---|---|
| `stream:scheduler:commands` | `schedule-orchestrator` | `schedule.run.requested` | Platform API / Cron Dispatcher | Schedule Orchestrator |
| `stream:jobs:execute` | `job-workers` | `job.node.execute` | Schedule Orchestrator | Job Worker |
| `stream:jobs:results` | `schedule-results` | `job.node.finished` | Job Worker | Schedule Orchestrator |
| 事件 | 写入方 | 处理方 |
|---|---|---|
| `schedule.run.requested` | Backend / Cron Dispatcher | Schedule Executor |
| `job.node.execute` | Schedule Executor | Schedule Executor Worker |
| `job.node.finished` | Schedule Executor Worker | Schedule Executor |
交付语义为至少一次。业务事务先写 `outbox_events`,发布成功后更新
Outbox;消费者处理前以 `consumer_inbox` 去重,业务更新与 Inbox 写入同一
MySQL 事务。只有业务事务提交成功后才确认 Redis 消息。
业务事务先写 `outbox_events`。Backend 对“立即运行”执行一次内部 HTTP 推送,
Executor 同时轮询 MySQL 作为兜底;`consumer_inbox` 防止同一事件重复执行。
## 5. 模块所有权
+8 -8
View File
@@ -1,11 +1,11 @@
# Event Schemas
# Database Event Schemas
Redis Streams 事件以 JSON Schema Draft 2020-12 定义:
内部事件以 JSON Schema Draft 2020-12 定义:
- `event-envelope-v1.json`:公共事件信封
- `schedule-run-requested-v1.json`:请求启动一次调度运行
- `job-node-execute-v1.json`:请求 Worker 执行一个稳定版本节点
- `job-node-finished-v1.json`Worker 报告节点终态。
- `event-envelope-v1.json`:公共事件信封
- `schedule-run-requested-v1.json`:请求启动一次调度运行
- `job-node-execute-v1.json`:请求执行一个稳定版本节点
- `job-node-finished-v1.json`:节点终态。
所有事件使用至少一次投递、Transactional Outbox 与 Consumer Inbox。
字段和状态值不得在生产者或消费者中另行定义。
事件先随业务事务写入 MySQL `outbox_events`Schedule Executor 直接轮询处理;
`consumer_inbox` 提供幂等保护。字段和状态值不得在生产者或消费者中另行定义。
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "event-envelope-v1.json",
"title": "Model Platform Event Envelope V1",
"description": "Redis Streams 中所有业务事件共用的不可变信封。",
"description": "MySQL Outbox 中所有内部业务事件共用的不可变信封。",
"type": "object",
"additionalProperties": false,
"required": [
+2 -3
View File
@@ -1,4 +1,3 @@
# 编辑锁契约
# File Edit Lock
当前版本见 [file-edit-lock-v1.md](file-edit-lock-v1.md)。Redis 是实时锁唯一
权威,MySQL `edit_sessions` 仅用于审计。
当前版本见 [file-edit-lock-v1.md](file-edit-lock-v1.md)。MySQL 租约是实时锁权威。
+16 -41
View File
@@ -8,56 +8,31 @@ POST /api/v1/file-locks/{edit_session_id}/heartbeat
DELETE /api/v1/file-locks/{edit_session_id}
```
三个接口均要求用户身份和 Workspace 身份。当前开发基线使用
`X-User-ID``X-Workspace-ID`,后续接入 JWT 时保持路径和业务 DTO 不变。
加锁成功返回一次性原始 `lock_token`。心跳和释放请求体均为:
三个接口均要求 `X-User-ID``X-Workspace-ID`。加锁成功返回一次性原始
`lock_token`;心跳和释放请求体均为:
```json
{"lock_token": "raw-token-returned-by-acquire"}
```
原始 token 只由客户端持有,禁止写入 MySQL 和日志
原始 token 只由客户端持有,数据库只保存 SHA-256 摘要
## Redis 数据
## MySQL 租约
```text
Key: lock:file:{workspace_id}:{storage_object_id}
TTL: 45000 ms
```
`edit_sessions` 是实时锁权威:
Value
- `session_status=active``expires_at > now()` 表示锁有效;
- 同一 `storage_object_id` 同时只能存在一个有效编辑会话;
- 心跳更新 `last_heartbeat_at``expires_at`
- 主动释放将状态改为 `closed`
- 后台清理将超时租约改为 `expired`
```json
{
"edit_session_id": "01J...",
"user_id": "01J...",
"display_name": "张三",
"token_hash": "sha256-hex",
"acquired_at": "UTC timestamp"
}
```
加锁必须使用:
```text
SET key value NX PX 45000
```
心跳和释放必须执行 Lua 原子操作,并同时比较
`edit_session_id + token_hash`
```text
heartbeat: compare owner -> PEXPIRE 45000
release: compare owner -> DEL
```
禁止使用 `GET` 后单独 `DEL`,也禁止在 MySQL 文件表增加 `is_locked`
加锁、心跳和释放都在数据库事务中校验 `edit_session_id + lock_token_hash`
部署时 Runtime 保持单副本;若扩展到多副本,应为加锁查询增加数据库行锁或唯一
租约表约束。
## 状态与错误
- Redis 是实时锁唯一权威。
- `edit_sessions` 记录 `active/closed/expired`,仅用于审计。
- 冲突返回 HTTP 409、错误码 `FILE_LOCK_CONFLICT`,并包含当前编辑者及租约到期时间
- 错误 token 返回 HTTP 403,且不得续期或释放现有锁。
- 浏览器建议每 15 秒心跳;关闭、断线或心跳超时后由主动释放或 TTL 释放。
- 冲突返回 HTTP 409、错误码 `FILE_LOCK_CONFLICT`,并包含当前编辑者和租约到期时间;
- 错误 token 返回 HTTP 403,且不得续期或释放现有锁;
- 浏览器建议每 15 秒心跳,默认租约为 45 秒
@@ -13,7 +13,7 @@
- 五段 Cron 校验与未来时间预览;
- DAG 完整性和有向无环校验。
立即运行、Cron 自动触发、Redis Streams 事件和 Worker 执行属于后续小步
立即运行、Cron 自动触发和 Executor 执行已经由独立 Schedule 服务承接
## 2. 请求上下文
@@ -0,0 +1,67 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "5f4293c4",
"metadata": {},
"source": [
"# 新建模型实验\\n在这里开始数据探索与模型构建。"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "694d9330",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Hello, Model Platform!\n"
]
}
],
"source": [
"print('Hello, Model Platform!')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6c70739e-0bd2-413d-bb0e-68ed8951d4bb",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1574172-4d3c-40cd-860e-f0f0b4822c49",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,9 @@
"""模型实验开发平台构建脚本。"""
def main() -> None:
print("Hello, Model Platform!")
if __name__ == "__main__":
main()
@@ -0,0 +1,89 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "5f4293c4",
"metadata": {},
"source": [
"# 新建模型实验\\n在这里开始数据探索与模型构建。"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "694d9330",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Hello, Model Platform!\n"
]
}
],
"source": [
"print('Hello, Model Platform!')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6c70739e-0bd2-413d-bb0e-68ed8951d4bb",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": 3,
"id": "a1574172-4d3c-40cd-860e-f0f0b4822c49",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'2026-07-30 10:38:49.218085'"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from datetime import datetime\n",
"str(datetime.now())"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2d521122-0ee9-4f39-b0a2-6d3a3b004916",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,9 @@
"""模型实验开发平台构建脚本。"""
def main() -> None:
print("Hello, Model Platform!")
if __name__ == "__main__":
main()
+71 -49
View File
@@ -1,4 +1,4 @@
name: ${COMPOSE_PROJECT_NAME:-model-platform-refactored}
name: ${COMPOSE_PROJECT_NAME:-model-platform-new}
x-app-environment: &app-environment
DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4
@@ -18,6 +18,7 @@ services:
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
- --default-time-zone=+08:00
ports:
- "${MYSQL_PORT:-3308}:3306"
volumes:
@@ -27,20 +28,7 @@ services:
interval: 10s
timeout: 5s
retries: 15
redis:
image: redis:7.2.5-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-model_platform_redis}"]
ports:
- "${REDIS_PORT:-6380}:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a '${REDIS_PASSWORD:-model_platform_redis}' ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
rustfs:
image: ${RUSTFS_IMAGE:-rustfs/rustfs:latest}
@@ -75,6 +63,16 @@ services:
- ./deploy/data/workspaces:/home/jovyan/work
expose:
- "8888"
healthcheck:
test:
- CMD
- python
- -c
- "import os, urllib.request; r=urllib.request.Request('http://127.0.0.1:8888/jupyter/api/status', headers={'Authorization':'Bearer '+os.environ['JUPYTER_TOKEN']}); urllib.request.urlopen(r, timeout=3)"
interval: 10s
timeout: 5s
retries: 15
start_period: 30s
migrate:
build:
@@ -82,15 +80,7 @@ services:
dockerfile: backend/Dockerfile
environment:
<<: *app-environment
command:
- uv
- run
- --frozen
- --package
- backend
- alembic
- upgrade
- head
command: ["alembic", "upgrade", "head"]
depends_on:
mysql:
condition: service_healthy
@@ -104,13 +94,24 @@ services:
environment:
<<: *app-environment
SERVICE_NAME: backend
READINESS_TARGETS: mysql:3306,redis:6379,rustfs:9000
READINESS_TARGETS: mysql:3306,rustfs:9000
RUNTIME_API_URL: http://runtime:8000
SCHEDULE_API_URL: http://schedule:8000
RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000
RUSTFS_PUBLIC_ENDPOINT: http://localhost:${RUSTFS_API_PORT:-9010}
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
RUSTFS_DEFAULT_BUCKET: model-platform
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
volumes:
- ./deploy/data/workspaces:/workspace/workspaces
ports:
@@ -120,8 +121,6 @@ services:
condition: service_completed_successfully
mysql:
condition: service_healthy
redis:
condition: service_healthy
rustfs:
condition: service_started
@@ -133,23 +132,32 @@ services:
environment:
<<: *app-environment
SERVICE_NAME: runtime-manager
READINESS_TARGETS: mysql:3306,redis:6379,jupyter:8888
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-model_platform_redis}
READINESS_TARGETS: mysql:3306,jupyter:8888
FILE_LOCK_ENABLED: "false"
FILE_LOCK_TTL_MS: "45000"
JUPYTER_INTERNAL_URL: http://jupyter:8888/jupyter/
JUPYTER_PROXY_BASE_PATH: /jupyter/
JUPYTER_TICKET_TTL_SECONDS: "300"
RUNTIME_LEASE_SECONDS: "1800"
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
ports:
- "${RUNTIME_PORT:-8012}:8000"
depends_on:
migrate:
condition: service_completed_successfully
mysql:
condition: service_healthy
redis:
condition: service_healthy
jupyter:
condition: service_started
condition: service_healthy
schedule:
build:
@@ -159,14 +167,21 @@ services:
environment:
<<: *app-environment
SERVICE_NAME: schedule-executor
READINESS_TARGETS: mysql:3306,redis:6379,rustfs:9000,backend:8000
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-model_platform_redis}
READINESS_TARGETS: mysql:3306,rustfs:9000,backend:8000
RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
STORAGE_API_URL: http://backend:8000
BACKEND_API_URL: http://backend:8000
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
volumes:
- ./deploy/data/workspaces:/workspace/workspaces
ports:
@@ -174,29 +189,36 @@ services:
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
backend:
rustfs:
condition: service_started
backend:
condition: service_healthy
gateway:
image: nginx:1.27-alpine
build:
context: .
dockerfile: nginx/Dockerfile
restart: unless-stopped
environment:
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:-local-internal-token}
volumes:
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
ports:
- "${GATEWAY_PORT:-8081}:80"
depends_on:
backend:
condition: service_started
condition: service_healthy
runtime:
condition: service_started
condition: service_healthy
schedule:
condition: service_healthy
jupyter:
condition: service_started
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 5s
volumes:
mysql_data:
redis_data:
rustfs_data:
+4
View File
@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/
+22 -1
View File
@@ -1 +1,22 @@
前端
# Frontend
React Router SPA,已将原前端按功能拆分到 `app/`
```text
app/components/ 通用图标组件
app/features/platform/ 主框架、脚本工作区和 Jupyter 编辑
app/features/schedules/ DAG 调度页面
app/features/admin/ 工作台与系统管理
app/routes/ React Router 路由入口
app/services/ API 请求、DTO 和演示上下文
app/styles/ 分功能样式
```
本地开发:
```bash
pnpm install
pnpm dev
```
生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。
+11
View File
@@ -0,0 +1,11 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #1f354b;
background: #f3f6f9;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-width: 1024px; min-height: 100%; }
button, input, select, textarea { font: inherit; }
+183
View File
@@ -0,0 +1,183 @@
type IconName =
| "brand"
| "home"
| "script"
| "schedule"
| "experiment"
| "database"
| "settings"
| "chevron"
| "search"
| "plus"
| "upload"
| "folder"
| "notebook"
| "python"
| "refresh"
| "close"
| "check"
| "info"
| "workspace"
| "menu"
| "external"
| "release"
| "play";
type IconProps = {
name: IconName;
size?: number;
strokeWidth?: number;
};
export default function Icon({
name,
size = 18,
strokeWidth = 1.8,
}: IconProps) {
const common = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
xmlns: "http://www.w3.org/2000/svg",
"aria-hidden": true,
};
if (name === "brand") {
return (
<svg {...common} viewBox="0 0 32 32">
<path
d="M16 3.5 27 9.8v12.4L16 28.5 5 22.2V9.8L16 3.5Z"
fill="currentColor"
opacity=".2"
/>
<path
d="M16 7.7 23.4 12v8L16 24.3 8.6 20v-8L16 7.7Z"
fill="currentColor"
/>
<circle cx="16" cy="16" r="3.3" fill="white" />
</svg>
);
}
const paths: Record<Exclude<IconName, "brand">, React.ReactNode> = {
home: (
<>
<path d="m3.5 11 8.5-7 8.5 7" />
<path d="M5.5 10v10h13V10M9.5 20v-6h5v6" />
</>
),
script: (
<>
<rect x="5" y="3.5" width="14" height="17" rx="2" />
<path d="M8.5 8h7M8.5 12h7M8.5 16h4.5" />
</>
),
schedule: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3.2 2" />
</>
),
experiment: (
<>
<path d="M9 3.5h6M10 3.5v5l-5.2 9a2 2 0 0 0 1.7 3h11a2 2 0 0 0 1.7-3L14 8.5v-5" />
<path d="M7.3 15h9.4" />
</>
),
database: (
<>
<ellipse cx="12" cy="5.5" rx="7.5" ry="3" />
<path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
<path d="M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
chevron: <path d="m9 7 5 5-5 5" />,
search: (
<>
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="m15.5 15.5 4 4" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
upload: (
<>
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" />
<path d="M4 15.5V20h16v-4.5" />
</>
),
folder: (
<path d="M3.5 6.5h6l2 2H20.5v10.5a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5V6.5Z" />
),
notebook: (
<>
<rect x="5" y="3.5" width="14" height="17" rx="2" />
<path d="M8.5 3.5v17M11.5 8h4M11.5 12h4M11.5 16h2.5" />
</>
),
python: (
<>
<path d="M8 4.5h5.5a2.5 2.5 0 0 1 2.5 2.5v3H8a3 3 0 0 0-3 3v2" />
<path d="M16 19.5h-5.5A2.5 2.5 0 0 1 8 17v-3h8a3 3 0 0 0 3-3V9" />
<circle cx="11" cy="7" r=".8" fill="currentColor" stroke="none" />
<circle cx="13" cy="17" r=".8" fill="currentColor" stroke="none" />
</>
),
refresh: (
<>
<path d="M19 8V4l-1.7 1.7A8 8 0 1 0 20 12" />
<path d="M19 4h-4" />
</>
),
close: <path d="m6 6 12 12M18 6 6 18" />,
check: <path d="m5 12.5 4 4L19 7" />,
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8h.01" />
</>
),
workspace: (
<>
<rect x="3.5" y="5" width="17" height="14" rx="2" />
<path d="M8 5V3.5h8V5M8 11h8M12 8v6" />
</>
),
menu: (
<>
<path d="M4 6h16M4 12h16M4 18h16" />
</>
),
external: (
<>
<path d="M14 5h5v5M19 5l-8 8" />
<path d="M18 13v6H5V6h6" />
</>
),
release: (
<>
<path d="M12 3.5v11M7.5 8 12 3.5 16.5 8" />
<path d="M5 12.5v7h14v-7" />
</>
),
play: <path d="m8 5 11 7-11 7V5Z" />,
};
return (
<svg
{...common}
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
>
{paths[name]}
</svg>
);
}
+256
View File
@@ -0,0 +1,256 @@
import { FormEvent, useEffect, useState } from "react";
import {
ApiRequestError,
createEmployee,
deleteEmployee,
demoContext,
listEmployees,
updateEmployee,
type Employee,
} from "../../services/api";
import Icon from "../../components/Icon";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
};
export function DashboardPage({
scriptCount,
online,
onNavigate,
}: {
scriptCount: number;
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
return (
<section className="dashboard-page">
<div className="dashboard-hero">
<div>
<span>MODEL DEVELOPMENT PLATFORM</span>
<h2>{demoContext.userName}</h2>
<p> {demoContext.workspaceName}</p>
</div>
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
</div>
<div className="dashboard-metrics">
<article><Icon name="script" /><span><b>{scriptCount}</b><small></small></span></article>
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
<article><Icon name="settings" /><span><b>4</b><small></small></span></article>
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small></small></span></article>
</div>
<div className="dashboard-actions">
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" /></button>
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" /></button>
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" /></button>
</div>
<div className="dashboard-grid">
<section className="dashboard-panel dashboard-panel--trend">
<header><div><span></span><h3> 7 </h3></div><b> 92.6%</b></header>
<div className="trend-chart">
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
<div className="trend-column" key={index}>
<span className="trend-column__value">{Math.round(value / 7)}</span>
<i style={{ height: `${value}%` }} />
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
</div>
))}
</div>
<footer><span><i className="legend-dot is-success" /> 75</span><span><i className="legend-dot is-failed" /> 6</span></footer>
</section>
<section className="dashboard-panel dashboard-panel--donut">
<header><div><span></span><h3></h3></div></header>
<div className="donut-layout">
<div className="donut-chart"><span><b>{scriptCount}</b><small></small></span></div>
<div className="donut-legend">
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} · 67%</small></span>
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} · 33%</small></span>
<span><i className="legend-dot is-version" /><b></b><small>3 </small></span>
</div>
</div>
</section>
<section className="dashboard-panel dashboard-panel--activity">
<header><div><span>ACTIVITY</span><h3></h3></div><button type="button"></button></header>
<div className="activity-table">
<div className="activity-table__head"><span></span><span></span><span></span><span></span></div>
{[
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
].map((row) => (
<div className="activity-row" key={row[0]}>
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
</div>
))}
</div>
</section>
</div>
</section>
);
}
const EMPTY_FORM = {
username: "",
display_name: "",
email: "",
role_code: "developer" as "admin" | "developer",
status: "active" as "active" | "disabled" | "locked",
};
export function SystemAdminPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const canManage = demoContext.roleCode === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await listEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "员工列表加载失败",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = (): void => {
setEditing(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (employee: Employee): void => {
setEditing(employee);
setForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
status: employee.status,
});
setDialogOpen(true);
};
const submit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
setSaving(true);
try {
if (editing) {
const updated = await updateEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
} else {
const created = await createEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
});
setEmployees((current) => [...current, created]);
}
setDialogOpen(false);
onNotify({ tone: "success", message: editing ? "员工信息已更新" : "员工已添加" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存员工失败",
});
} finally {
setSaving(false);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
try {
await deleteEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "员工已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除员工失败",
});
}
};
return (
<section className="admin-page">
<header className="admin-page__header">
<div><span></span><h2></h2><p>{demoContext.workspaceName} · {employees.length} </p></div>
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />
</button>
</header>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head"><span></span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="admin-empty"></p> : employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}></button>
</span>
</div>
);
})}
</div>
{dialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header"><div><span className="modal__eyebrow">EMPLOYEE</span><h2>{editing ? "编辑员工" : "添加员工"}</h2></div><button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field"><span></span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
<label className="form-field"><span></span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
<label className="form-field"><span></span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
<label className="form-field"><span></span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer"></option><option value="admin"></option></select></label>
{editing && <label className="form-field"><span></span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active"></option><option value="disabled"></option><option value="locked"></option></select></label>}
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
</form>
</section>
</div>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "页面加载失败";
let details = "系统发生了未预期的错误。";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "请求失败";
details =
error.status === 404
? "没有找到请求的页面。"
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [route("*", "routes/platform.tsx")] satisfies RouteConfig;
+13
View File
@@ -0,0 +1,13 @@
import type { Route } from "./+types/platform";
import ModelPlatformApp from "../features/platform/ModelPlatformApp";
export function meta({}: Route.MetaArgs) {
return [
{ title: "模型实验开发平台" },
{ name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
];
}
export default function PlatformRoute() {
return <ModelPlatformApp />;
}
+853
View File
@@ -0,0 +1,853 @@
export type DemoUser = {
userId: string;
userName: string;
username: string;
roleCode: "admin" | "developer";
roleName: string;
};
export type DemoWorkspace = {
workspaceId: string;
workspaceName: string;
};
export const demoUsers: DemoUser[] = [
{ userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
{ userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
];
export const demoWorkspaces: DemoWorkspace[] = [
{ workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
{ workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
];
function readStoredContext(): Partial<{
userId: string;
workspaceId: string;
}> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(
window.localStorage.getItem("model-platform-demo-context") ?? "{}",
) as Partial<{ userId: string; workspaceId: string }>;
} catch {
return {};
}
}
const storedContext = readStoredContext();
const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
?? demoUsers[0];
const initialWorkspace = demoWorkspaces.find(
(item) => item.workspaceId === storedContext.workspaceId,
) ?? demoWorkspaces[0];
export const demoContext = {
...initialUser,
...initialWorkspace,
};
export function setDemoContext(input: {
user?: DemoUser;
workspace?: DemoWorkspace;
}): void {
if (input.user) Object.assign(demoContext, input.user);
if (input.workspace) Object.assign(demoContext, input.workspace);
if (typeof window !== "undefined") {
window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
userId: demoContext.userId,
workspaceId: demoContext.workspaceId,
}));
}
}
export type ScriptType = "python" | "notebook";
export type Visibility = "private" | "workspace" | "public";
export type Employee = {
user_id: string;
username: string;
display_name: string;
email: string | null;
status: "active" | "disabled" | "locked";
role_code: "admin" | "developer";
role_name: string;
created_at: string;
};
export type ScriptItem = {
script_id: string;
workspace_id: string;
current_object_id: string;
owner_user_id: string;
script_name: string;
script_type: ScriptType;
visibility: Visibility;
status: string;
relative_path: string;
content_hash: string;
size_bytes: number;
created_at: string;
updated_at: string;
};
export type WorkspaceDirectory = {
path: string;
name: string;
parent_path: string;
};
type ApiEnvelope<T> = {
request_id: string;
data: T;
meta: Record<string, unknown>;
};
type ApiErrorEnvelope = {
detail?: string | {
code?: string;
message?: string;
};
error?: {
code?: string;
message?: string;
details?: {
editor_name?: string;
lease_expires_at?: string;
};
};
};
export class ApiRequestError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}
async function apiRequest<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const response = await fetch(path, {
...init,
credentials: "same-origin",
headers: {
"X-User-ID": demoContext.userId,
"X-Workspace-ID": demoContext.workspaceId,
"X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const payload = (await response.json().catch(() => ({}))) as
| ApiEnvelope<T>
| ApiErrorEnvelope;
if (!response.ok) {
const error = payload as ApiErrorEnvelope;
const detailMessage = typeof error.detail === "string"
? error.detail
: error.detail?.message;
const editor = error.error?.details?.editor_name;
throw new ApiRequestError(
(editor ? `${error.error?.message ?? "文件正在编辑"}${editor}` : undefined)
?? error.error?.message
?? detailMessage
?? `请求失败(HTTP ${response.status}`,
response.status,
typeof error.detail === "object"
? error.detail?.code
: error.error?.code,
);
}
return (payload as ApiEnvelope<T>).data;
}
export async function listScripts(): Promise<ScriptItem[]> {
return apiRequest<ScriptItem[]>("/api/v1/scripts");
}
function initialContent(scriptType: ScriptType): string {
if (scriptType === "python") {
return [
'"""模型实验开发平台构建脚本。"""',
"",
"",
"def main() -> None:",
' print("Hello, Model Platform!")',
"",
"",
'if __name__ == "__main__":',
" main()",
"",
].join("\n");
}
return JSON.stringify(
{
cells: [
{
cell_type: "markdown",
metadata: {},
source: ["# 新建模型实验\\n", "在这里开始数据探索与模型构建。"],
},
{
cell_type: "code",
execution_count: null,
metadata: {},
outputs: [],
source: ["print('Hello, Model Platform!')\\n"],
},
],
metadata: {
kernelspec: {
display_name: "Python 3",
language: "python",
name: "python3",
},
language_info: {
name: "python",
version: "3.12",
},
},
nbformat: 4,
nbformat_minor: 5,
},
null,
2,
);
}
export async function createScript(input: {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath?: string | null;
}): Promise<ScriptItem> {
return apiRequest<ScriptItem>("/api/v1/scripts", {
method: "POST",
body: JSON.stringify({
script_name: input.name.trim(),
script_type: input.scriptType,
visibility: input.visibility,
content: initialContent(input.scriptType),
parent_path: input.parentPath,
}),
});
}
export async function uploadScript(
file: File,
parentPath = "",
visibility: Visibility = "workspace",
): Promise<ScriptItem> {
const parameters = new URLSearchParams({
file_name: file.name,
parent_path: parentPath,
visibility,
});
return apiRequest<ScriptItem>(
`/api/v1/scripts/upload?${parameters.toString()}`,
{
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file,
},
);
}
export async function deleteScript(
scriptId: string,
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
}
export async function listWorkspaceDirectories(): Promise<
WorkspaceDirectory[]
> {
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
"/api/v1/workspace-tree",
);
return data.directories;
}
export async function createWorkspaceDirectory(
directoryName: string,
parentPath = "",
): Promise<WorkspaceDirectory> {
return apiRequest<WorkspaceDirectory>("/api/v1/workspace-directories", {
method: "POST",
body: JSON.stringify({
directory_name: directoryName,
parent_path: parentPath,
}),
});
}
export async function deleteWorkspaceDirectory(
path: string,
): Promise<{
path: string;
status: string;
deleted_scripts: number;
versions_preserved: boolean;
}> {
const parameters = new URLSearchParams({ path });
return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
method: "DELETE",
});
}
export type FileLockSession = {
edit_session_id: string;
workspace_id: string;
storage_object_id: string;
user_id: string;
session_status: "active" | "closed" | "expired";
lease_seconds: number;
heartbeat_interval_seconds: number;
expires_at: string;
runtime_id: string;
jupyter_session_id: string;
jupyter_url?: string;
relative_path?: string;
lock_token?: string;
};
export type ActiveEditSession = FileLockSession & {
script_id: string;
script_name: string;
lock_token: string;
ticket_expires_at?: string;
};
export type JupyterAccessTicket = {
edit_session_id: string;
jupyter_url: string;
expires_at: string;
};
export type StableVersion = {
versions_id: string;
workspace_id: string;
script_id: string;
source_object_id: string;
artifact_object_id: string;
version_no: number;
version_label: string;
source_path: string;
artifact_path: string;
content_hash: string;
file_size_bytes: number;
visibility: Visibility;
release_note: string | null;
created_by: string;
created_at: string;
};
export async function acquireFileLock(
script: ScriptItem,
): Promise<ActiveEditSession> {
const session = await apiRequest<FileLockSession>(
`/api/v1/files/${script.current_object_id}/lock`,
{ method: "POST" },
);
if (!session.lock_token) {
throw new Error("加锁成功响应缺少 lock_token");
}
return {
...session,
script_id: script.script_id,
script_name: script.script_name,
lock_token: session.lock_token,
};
}
export async function heartbeatFileLock(
session: ActiveEditSession,
): Promise<FileLockSession> {
return apiRequest<FileLockSession>(
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
{
method: "POST",
body: JSON.stringify({ lock_token: session.lock_token }),
},
);
}
export async function releaseFileLock(
session: ActiveEditSession,
): Promise<FileLockSession> {
return apiRequest<FileLockSession>(
`/api/v1/file-locks/${session.edit_session_id}`,
{
method: "DELETE",
body: JSON.stringify({ lock_token: session.lock_token }),
},
);
}
export function releaseFileLockOnUnload(session: ActiveEditSession): void {
void fetch(`/api/v1/file-locks/${session.edit_session_id}`, {
method: "DELETE",
credentials: "same-origin",
keepalive: true,
headers: {
"Content-Type": "application/json",
"X-User-ID": demoContext.userId,
"X-Workspace-ID": demoContext.workspaceId,
"X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
},
body: JSON.stringify({ lock_token: session.lock_token }),
});
}
export async function createJupyterAccessTicket(
session: ActiveEditSession,
): Promise<JupyterAccessTicket> {
return apiRequest<JupyterAccessTicket>("/api/v1/jupyter/access-tickets", {
method: "POST",
body: JSON.stringify({
edit_session_id: session.edit_session_id,
lock_token: session.lock_token,
}),
});
}
export async function listScriptVersions(
scriptId: string,
): Promise<StableVersion[]> {
return apiRequest<StableVersion[]>(`/api/v1/scripts/${scriptId}/versions`);
}
export async function publishScriptVersion(input: {
script: ScriptItem;
releaseNote: string;
visibility: Visibility;
}): Promise<StableVersion> {
return apiRequest<StableVersion>(
`/api/v1/scripts/${input.script.script_id}/versions`,
{
method: "POST",
body: JSON.stringify({
source_object_id: input.script.current_object_id,
release_note: input.releaseNote.trim() || null,
visibility: input.visibility,
}),
},
);
}
export type ScheduleArtifact = {
versions_id: string;
version_label: string;
script_id: string;
script_name: string;
script_type: ScriptType;
content_hash: string;
file_size_bytes: number;
visibility: Visibility;
created_by: string;
created_at: string;
};
export type ScheduleNode = {
node_id: string;
schedule_id: string;
node_key: string;
node_name: string;
versions_id: string;
timeout_seconds: number;
retry_count: number;
retry_interval_sec: number;
position_x: number;
position_y: number;
arguments_json: Record<string, unknown>;
env_refs_json: Record<string, string>;
created_at: string;
updated_at: string;
version: {
versions_id: string;
version_label: string;
script_id: string;
script_name: string;
script_type: ScriptType;
content_hash: string;
created_at: string;
};
};
export type ScheduleEdge = {
edge_id: string;
schedule_id: string;
source_node_id: string;
target_node_id: string;
condition_expr: string | null;
created_at: string;
};
export type DagValidation = {
valid: boolean;
node_count: number;
edge_count: number;
root_node_ids: string[];
leaf_node_ids: string[];
topological_order: string[];
errors: Array<{
code: string;
message: string;
edge_id?: string;
node_ids?: string[];
}>;
};
export type Schedule = {
schedule_id: string;
workspace_id: string;
schedule_name: string;
description: string | null;
trigger_type: "manual" | "cron" | "api";
cron_expression: string | null;
timezone: string;
enabled: boolean;
workflow_version: number;
max_concurrency: number;
failure_policy: "stop" | "continue";
last_run_at: string | null;
next_run_at: string | null;
created_by: string;
updated_by: string;
created_at: string;
updated_at: string;
node_count: number;
edge_count: number;
nodes: ScheduleNode[];
edges: ScheduleEdge[];
dag_validation: DagValidation;
};
export type CronPreview = {
cron_expression: string;
timezone: string;
base_time: string;
occurrences: Array<{
local_time: string;
utc_time: string;
}>;
};
export type ScheduleRunStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "cancelled"
| "timed_out";
export type ScheduleNodeRunStatus =
| ScheduleRunStatus
| "skipped";
export type ScheduleRunSummary = {
run_id: string;
schedule_id: string;
workspace_id: string;
workflow_version: number;
trigger_type: "manual" | "cron" | "api" | "retry";
run_status: ScheduleRunStatus;
state_version: number;
queued_at: string;
started_at: string | null;
finished_at: string | null;
duration_ms: number | null;
error_code: string | null;
error_message: string | null;
logs_object_id: string | null;
result_object_id: string | null;
};
export type ScheduleNodeRun = {
node_run_id: string;
run_id: string;
node_id: string;
versions_id: string;
attempt_no: number;
node_status: ScheduleNodeRunStatus;
state_version: number;
started_at: string | null;
finished_at: string | null;
duration_ms: number | null;
exit_code: number | null;
message: string | null;
logs_object_id: string | null;
result_object_id: string | null;
};
export type ScheduleRunDetail = ScheduleRunSummary & {
node_runs: ScheduleNodeRun[];
};
export async function listSchedules(): Promise<Schedule[]> {
return apiRequest<Schedule[]>("/api/v1/schedules");
}
export async function getSchedule(scheduleId: string): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`);
}
export async function createSchedule(input: {
schedule_name: string;
description?: string | null;
trigger_type?: "manual" | "cron" | "api";
cron_expression?: string | null;
timezone?: string;
enabled?: boolean;
max_concurrency?: number;
failure_policy?: "stop" | "continue";
}): Promise<Schedule> {
return apiRequest<Schedule>("/api/v1/schedules", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateSchedule(
scheduleId: string,
input: {
workflow_version: number;
schedule_name?: string;
description?: string | null;
trigger_type?: "manual" | "cron" | "api";
cron_expression?: string | null;
timezone?: string;
enabled?: boolean;
max_concurrency?: number;
failure_policy?: "stop" | "continue";
},
): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function deleteSchedule(
scheduleId: string,
workflowVersion: number,
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
return apiRequest(`/api/v1/schedules/${scheduleId}`, {
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion }),
});
}
export async function listScheduleArtifacts(): Promise<ScheduleArtifact[]> {
return apiRequest<ScheduleArtifact[]>("/api/v1/schedule-artifacts");
}
export async function hideScheduleArtifact(
versionsId: string,
): Promise<{
versions_id: string;
deleted: boolean;
artifact_preserved: boolean;
}> {
return apiRequest(`/api/v1/versions/${versionsId}`, {
method: "DELETE",
});
}
export async function listEmployees(): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/admin/employees");
}
export async function createEmployee(input: {
username: string;
display_name: string;
email?: string | null;
role_code: "admin" | "developer";
}): Promise<Employee> {
return apiRequest<Employee>("/api/v1/admin/employees", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateEmployee(
userId: string,
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
status?: "active" | "disabled" | "locked";
},
): Promise<Employee> {
return apiRequest<Employee>(`/api/v1/admin/employees/${userId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function deleteEmployee(
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(`/api/v1/admin/employees/${userId}`, {
method: "DELETE",
});
}
export async function createScheduleNode(
scheduleId: string,
input: {
workflow_version: number;
node_key: string;
node_name: string;
versions_id: string;
timeout_seconds?: number;
retry_count?: number;
retry_interval_sec?: number;
position_x?: number;
position_y?: number;
arguments_json?: Record<string, unknown>;
env_refs_json?: Record<string, string>;
},
): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/nodes`, {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateScheduleNode(
scheduleId: string,
nodeId: string,
input: {
workflow_version: number;
node_name?: string;
versions_id?: string;
timeout_seconds?: number;
retry_count?: number;
retry_interval_sec?: number;
position_x?: number;
position_y?: number;
arguments_json?: Record<string, unknown>;
env_refs_json?: Record<string, string>;
},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{
method: "PUT",
body: JSON.stringify(input),
},
);
}
export async function deleteScheduleNode(
scheduleId: string,
nodeId: string,
workflowVersion: number,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion }),
},
);
}
export async function createScheduleEdge(
scheduleId: string,
input: {
workflow_version: number;
source_node_id: string;
target_node_id: string;
condition_expr?: string | null;
},
): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/edges`, {
method: "POST",
body: JSON.stringify(input),
});
}
export async function deleteScheduleEdge(
scheduleId: string,
edgeId: string,
workflowVersion: number,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
{
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion }),
},
);
}
export async function validateSchedule(
scheduleId: string,
): Promise<DagValidation & {
schedule_id: string;
workflow_version: number;
}> {
return apiRequest(`/api/v1/schedules/${scheduleId}/validate`, {
method: "POST",
});
}
export async function previewCron(input: {
cron_expression: string;
timezone: string;
count?: number;
base_time?: string;
}): Promise<CronPreview> {
return apiRequest<CronPreview>("/api/v1/cron/preview", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function runScheduleNow(
scheduleId: string,
): Promise<ScheduleRunDetail> {
return apiRequest<ScheduleRunDetail>(
`/api/v1/schedules/${scheduleId}/run`,
{
method: "POST",
headers: {
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ reason: "manual_run" }),
},
);
}
export async function listScheduleRuns(input: {
scheduleId?: string;
status?: ScheduleRunStatus;
limit?: number;
} = {}): Promise<ScheduleRunSummary[]> {
const query = new URLSearchParams();
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
if (input.status) query.set("status", input.status);
query.set("limit", String(input.limit ?? 20));
return apiRequest<ScheduleRunSummary[]>(
`/api/v1/schedule-runs?${query.toString()}`,
);
}
export async function getScheduleRun(
runId: string,
): Promise<ScheduleRunDetail> {
return apiRequest<ScheduleRunDetail>(`/api/v1/schedule-runs/${runId}`);
}
+1
View File
@@ -0,0 +1 @@
.dashboard-page,.admin-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.dashboard-hero{display:flex;align-items:center;justify-content:space-between;padding:34px;border-radius:12px;color:#fff;background:linear-gradient(125deg,#0b3d69,#1978d4);box-shadow:0 12px 30px rgb(19 80 137/18%)}.dashboard-hero span{font-size:10px;letter-spacing:.12em;opacity:.82}.dashboard-hero h2{margin:8px 0 5px;font-size:25px}.dashboard-hero p{margin:0;font-size:12px;opacity:.88}.dashboard-hero__badge{padding:8px 12px;border-radius:20px;background:rgb(255 255 255/16%)}.dashboard-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px}.dashboard-metrics article{display:flex;align-items:center;gap:14px;padding:20px;border:1px solid #e0e8ef;border-radius:9px;background:#fff}.dashboard-metrics svg{color:#1978d4}.dashboard-metrics span{display:flex;flex-direction:column}.dashboard-metrics b{color:#20364c;font-size:21px}.dashboard-metrics small{color:#8796a6}.dashboard-actions{display:flex;gap:12px;margin-top:18px}.dashboard-actions button{display:flex;align-items:center;gap:8px;padding:13px 18px;border:1px solid #d8e4ef;border-radius:7px;color:#346587;background:#fff;cursor:pointer}.admin-page__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:20px 22px;border:1px solid #dee7ef;border-radius:9px;background:#fff}.admin-page__header span{color:#1978d4;font-size:10px;font-weight:800}.admin-page__header h2{margin:4px 0;color:#20364c}.admin-page__header p{margin:0;color:#8a99a8;font-size:11px}.admin-readonly{margin-bottom:12px;padding:10px 13px;border:1px solid #f1d79c;border-radius:6px;color:#8a6416;background:#fff8e8;font-size:11px}.employee-table{overflow:hidden;border:1px solid #dfe7ee;border-radius:9px;background:#fff}.employee-table__head,.employee-row{display:grid;grid-template-columns:1.5fr 1fr .7fr .65fr .8fr;align-items:center;gap:12px;padding:12px 17px}.employee-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.employee-row{min-height:64px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.employee-name{display:flex;align-items:center;gap:10px}.employee-name .avatar{display:grid;width:32px;height:32px;place-items:center}.employee-name>span{display:flex;min-width:0;flex-direction:column}.employee-name strong{color:#23384e}.employee-name small{margin-top:3px;color:#93a0ae}.employee-row code{color:#60758b}.role-pill,.status-pill{width:max-content;padding:4px 8px;border-radius:12px}.role-pill{color:#1d6ab3;background:#eaf4ff}.role-pill.is-admin{color:#87580f;background:#fff2d6}.status-pill{color:#138657;background:#e8f8f1}.status-pill.is-disabled,.status-pill.is-locked{color:#a64b4b;background:#ffeded}.employee-actions{display:flex;gap:6px}.employee-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.employee-actions button.is-danger{color:#c14a4a}.employee-actions button:disabled{cursor:not-allowed;opacity:.45}.admin-empty{padding:25px;text-align:center;color:#8796a6}
+185
View File
@@ -0,0 +1,185 @@
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(310px, .9fr);
gap: 14px;
margin-top: 18px;
padding-bottom: 25px;
}
.dashboard-panel {
border: 1px solid #dfe7ee;
border-radius: 10px;
background: #fff;
box-shadow: 0 5px 16px rgb(33 65 97 / 5%);
}
.dashboard-panel > header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 17px 20px;
border-bottom: 1px solid #edf1f5;
}
.dashboard-panel header span {
color: #7890a6;
font-size: 9px;
font-weight: 800;
letter-spacing: .09em;
}
.dashboard-panel h3 {
margin: 4px 0 0;
color: #263b50;
font-size: 14px;
}
.dashboard-panel--trend > header > b {
padding: 5px 9px;
border-radius: 13px;
color: #16855a;
background: #e8f8f1;
font-size: 10px;
}
.trend-chart {
display: flex;
height: 190px;
align-items: flex-end;
gap: 18px;
padding: 26px 28px 17px;
background: repeating-linear-gradient(to bottom, #fff 0, #fff 44px, #eef3f7 45px);
}
.trend-column {
display: flex;
height: 100%;
flex: 1;
align-items: center;
justify-content: flex-end;
flex-direction: column;
gap: 5px;
}
.trend-column i {
display: block;
width: min(36px, 72%);
min-height: 12px;
border-radius: 5px 5px 2px 2px;
background: linear-gradient(#49a0eb, #207bd1);
box-shadow: 0 4px 9px rgb(32 123 209 / 18%);
}
.trend-column__value, .trend-column small {
color: #7890a3;
font-size: 9px;
}
.dashboard-panel--trend footer {
display: flex;
justify-content: center;
gap: 22px;
padding: 10px;
color: #708397;
font-size: 9px;
}
.dashboard-panel footer span {
display: flex;
align-items: center;
gap: 5px;
}
.legend-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
}
.legend-dot.is-success { background: #27b87c; }
.legend-dot.is-failed { background: #ed7777; }
.legend-dot.is-notebook { background: #318de0; }
.legend-dot.is-python { background: #72c9b0; }
.legend-dot.is-version { background: #f3b75c; }
.donut-layout {
display: flex;
min-height: 226px;
align-items: center;
justify-content: center;
gap: 28px;
padding: 20px;
}
.donut-chart {
position: relative;
display: grid;
width: 128px;
height: 128px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: conic-gradient(#318de0 0 67%, #72c9b0 67% 86%, #f3b75c 86% 100%);
}
.donut-chart::after {
position: absolute;
width: 78px;
height: 78px;
border-radius: 50%;
background: #fff;
content: "";
}
.donut-chart span {
z-index: 1;
display: flex;
align-items: center;
flex-direction: column;
}
.donut-chart b { color: #273d53; font-size: 20px; }
.donut-chart small { color: #8a9aaa; font-size: 8px; }
.donut-legend { display: flex; flex-direction: column; gap: 15px; }
.donut-legend > span {
display: grid;
grid-template-columns: 8px 1fr;
align-items: center;
column-gap: 7px;
}
.donut-legend b { color: #40566c; font-size: 10px; }
.donut-legend small { grid-column: 2; color: #8a9baa; font-size: 8px; }
.dashboard-panel--activity { grid-column: 1 / -1; }
.dashboard-panel--activity > header > button {
border: 0;
color: #257bcb;
background: transparent;
font-size: 10px;
}
.activity-table__head, .activity-row {
display: grid;
grid-template-columns: 2fr .7fr .55fr .5fr;
align-items: center;
gap: 15px;
padding: 11px 20px;
}
.activity-table__head {
color: #8595a5;
background: #f7f9fb;
font-size: 9px;
font-weight: 700;
}
.activity-row {
border-top: 1px solid #edf1f5;
color: #607488;
font-size: 10px;
}
.activity-row > span:first-child {
display: flex;
align-items: center;
gap: 9px;
color: #344c63;
}
.activity-icon {
display: grid;
width: 25px;
height: 25px;
place-items: center;
border-radius: 6px;
color: #277fcc;
background: #eaf4ff;
}
.activity-row b {
padding: 3px 7px;
border-radius: 10px;
color: #16855a;
background: #e9f8f2;
font-size: 9px;
}
@media (max-width: 1200px) {
.dashboard-grid { grid-template-columns: 1fr; }
.dashboard-panel--activity { grid-column: auto; }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/node": "^8",
"@react-router/serve": "^8",
"isbot": "^5.1.36",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8"
},
"devDependencies": {
"@react-router/dev": "^8",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.3"
},
"packageManager": "pnpm@10.15.1"
}
+2327
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+7
View File
@@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
} satisfies Config;
+26
View File
@@ -0,0 +1,26 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}
+21
View File
@@ -0,0 +1,21 @@
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouter()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"/api": {
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
changeOrigin: true,
},
"/jupyter": {
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
changeOrigin: true,
ws: true,
},
},
},
});
+18 -61
View File
@@ -1,71 +1,28 @@
# Migrations
MySQL 8 Alembic 迁移目录。
MySQL 8 Alembic 迁移目录。
当前数据库设计基线位于 `4/model_platform_schema.sql`
当前链路:
## V1 基线
- Alembic`1.18.5`
- 基线版本:`20260724_0001`
- 迁移文件:`versions/20260724_0001_v1_schema_baseline.py`
- 业务表:26 张
- 外键:71 个
- 索引:94 个
- `upgrade()`:可以从空 MySQL 8 数据库建立完整 V1 Schema。
- `downgrade()`:按反向依赖顺序删除 26 张业务表。
现有 `model_platform` 数据库原本由 V1 DDL 建立,结构校验一致后已使用
`alembic stamp head` 接管,没有重复执行建表。
## 常用命令
所有命令必须通过环境变量传入连接串,仓库中不保存数据库密码:
```powershell
$env:DATABASE_URL = "mysql+asyncmy://<user>:<password>@<host>:3306/<database>?charset=utf8mb4"
alembic -c alembic.ini current
alembic -c alembic.ini check
alembic -c alembic.ini upgrade head
```text
20260724_0001 完整业务 Schema 基线
20260728_0002 Demo 用户、角色、Workspace 数据
20260728_0003 调度稳定版本可见性字段
20260730_0004 编辑租约字段 redis_lock_key -> lock_key 兼容迁移
```
迁移发布后不得直接修改旧版本;表结构变化应新增 revision,并评审自动生成的
数据类型、约束、索引、默认值和回滚顺序。
Docker Compose 中的 `migrate` 一次性容器会在 Backend 和 Runtime 启动前执行:
## 第 8 小步验收记录
```bash
alembic upgrade head
```
- 执行日期:2026-07-24
- 目标服务:Docker Compose `mysql`
- 数据库:`model_platform`
- MySQL8.0.36
- 空库升级:通过
- 模型与数据库结构校验:26 张表、71 个外键、差异 0
- `alembic check``No new upgrade operations detected`
- 回滚到 base:通过,剩余业务表 0
- 临时测试数据库:验收后已删除
- 现有开发库版本:`20260724_0001 (head)`
本地执行:
## 第 9 小步数据迁移
```bash
export DATABASE_URL='mysql+asyncmy://user:password@127.0.0.1:3308/model_platform?charset=utf8mb4'
uv run --package backend alembic current
uv run --package backend alembic upgrade head
```
旧版 `文件1/platform_data/system.json` 已通过
`data/migrate_system_json.py` 事务化迁移到 MySQL
- `roles`2 行;
- `permissions`13 行;
- `role_permissions`21 行;
- `users`4 行;
- `audit_logs`26 行。
迁移工具默认 dry-run,显式 `--apply` 才写库;重复执行不会重复写入。
## 第 10 小步 Workspace 迁移
旧版 `文件1/server.py` 中的 `WORKSPACE_DEFINITIONS` 已通过
`data/migrate_legacy_workspaces.py` 事务化迁移到 MySQL
- `workspaces`2 行;
- `workspace_members`4 行;
- 已有 `audit_logs.workspace_id`:按用户唯一 Workspace 关系补齐 26 行。
迁移工具使用 AST 读取静态常量,不执行旧版服务代码;默认 dry-run,显式
`--apply` 才写库。重复执行与后端镜像内隔离 dry-run 的变更数均为 0。
已发布的旧 revision 不应再修改;后续表结构调整必须新增 revision。
@@ -26,11 +26,11 @@ def upgrade() -> None:
sa.Column('event_id', sa.CHAR(length=26), nullable=False),
sa.Column('process_status', sa.String(length=16), server_default=sa.text("'processing'"), nullable=False, comment='processing/succeeded/failed'),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('message_id', sa.String(length=128), nullable=True, comment='Redis Stream message ID'),
sa.Column('message_id', sa.String(length=128), nullable=True, comment='数据库事件处理批次标识'),
sa.Column('processed_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('error_message', sa.String(length=2000), nullable=True),
sa.PrimaryKeyConstraint('consumer_name', 'event_id'),
comment='消费者幂等 Inbox,防止 Stream 重投导致重复执行'
comment='消费者幂等 Inbox,防止数据库事件重复处理'
)
op.create_index('idx_consumer_inbox_status', 'consumer_inbox', ['consumer_name', 'process_status', 'created_at'], unique=False)
op.create_table('outbox_events',
@@ -49,7 +49,7 @@ def upgrade() -> None:
sa.Column('published_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('last_error', sa.String(length=2000), nullable=True),
sa.PrimaryKeyConstraint('event_id'),
comment='事务 Outbox提交后发布到 Redis Streams'
comment='事务 Outbox由 Schedule Executor 直接轮询处理'
)
op.create_index('idx_outbox_aggregate', 'outbox_events', ['aggregate_type', 'aggregate_id', 'created_at'], unique=False)
op.create_index('idx_outbox_idempotency', 'outbox_events', ['idempotency_key'], unique=False)
@@ -295,7 +295,7 @@ def upgrade() -> None:
sa.Column('workspace_id', sa.CHAR(length=26), nullable=False),
sa.Column('storage_object_id', sa.CHAR(length=26), nullable=False),
sa.Column('user_id', sa.CHAR(length=26), nullable=False),
sa.Column('redis_lock_key', sa.String(length=512), nullable=False),
sa.Column('lock_key', sa.String(length=512), nullable=False),
sa.Column('lock_token_hash', sa.BINARY(length=32), nullable=False, comment='不保存原始 token'),
sa.Column('session_status', sa.String(length=16), server_default=sa.text("'active'"), nullable=False, comment='active/closed/expired/failed'),
sa.Column('started_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
@@ -310,7 +310,7 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name='fk_edit_sessions_user', ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], name='fk_edit_sessions_workspace', ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('edit_session_id'),
comment='编辑会话审计;实时锁状态以 Redis 为准'
comment='编辑会话与数据库租约;MySQL 为锁状态权威'
)
op.create_index('fk_edit_sessions_workspace', 'edit_sessions', ['workspace_id'], unique=False)
op.create_index('idx_edit_sessions_object', 'edit_sessions', ['storage_object_id', 'session_status', 'expires_at'], unique=False)
@@ -0,0 +1,49 @@
"""remove Redis-specific lock column naming
Revision ID: 20260730_0004
Revises: 20260728_0003
Create Date: 2026-07-30
"""
from collections.abc import Sequence
from alembic import context, op
import sqlalchemy as sa
revision: str = "20260730_0004"
down_revision: str | None = "20260728_0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _column_names() -> set[str]:
inspector = sa.inspect(op.get_bind())
return {item["name"] for item in inspector.get_columns("edit_sessions")}
def upgrade() -> None:
if context.is_offline_mode():
return
columns = _column_names()
if "redis_lock_key" in columns and "lock_key" not in columns:
op.alter_column(
"edit_sessions",
"redis_lock_key",
new_column_name="lock_key",
existing_type=sa.String(length=512),
existing_nullable=False,
)
def downgrade() -> None:
if context.is_offline_mode():
return
columns = _column_names()
if "lock_key" in columns and "redis_lock_key" not in columns:
op.alter_column(
"edit_sessions",
"lock_key",
new_column_name="redis_lock_key",
existing_type=sa.String(length=512),
existing_nullable=False,
)
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS frontend-build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM nginx:1.27-alpine
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=frontend-build /app/build/client /usr/share/nginx/html
EXPOSE 80
+83
View File
@@ -0,0 +1,83 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend_upstream {
server backend:8000;
}
upstream runtime_upstream {
server runtime:8000;
}
upstream jupyter_upstream {
server jupyter:8888;
}
server {
listen 80;
server_name _;
client_max_body_size 100m;
location = /health {
default_type application/json;
return 200 '{"status":"ok","service":"gateway"}';
}
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
}
location = /_jupyter_auth {
internal;
proxy_pass http://runtime_upstream/internal/v1/jupyter/authorize;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Service-Token "${INTERNAL_SERVICE_TOKEN}";
}
location = /jupyter {
return 308 /jupyter/;
}
location ^~ /jupyter/ {
auth_request /_jupyter_auth;
auth_request_set $jupyter_authorization
$upstream_http_x_jupyter_authorization;
proxy_pass http://jupyter_upstream;
proxy_http_version 1.1;
proxy_set_header Authorization $jupyter_authorization;
proxy_set_header Host $http_host;
proxy_set_header Origin $http_origin;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /jupyter;
proxy_set_header X-Request-ID $request_id;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
}
}
+2 -3
View File
@@ -3,11 +3,10 @@ FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
COPY pyproject.toml uv.lock ./
COPY common ./common
COPY contracts ./contracts
COPY runtime ./runtime
RUN uv sync --frozen --no-dev --no-editable --package runtime
RUN uv pip install --system ./common ./runtime
EXPOSE 8000
CMD ["uv", "run", "--frozen", "--package", "runtime", "uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"]
+9 -2
View File
@@ -1,4 +1,11 @@
# Runtime
独立 Runtime/Jupyter 管理服务。负责 Workspace Runtime、Notebook Session、
Jupyter 访问票据及可选编辑锁。Demo 默认关闭互斥锁。
独立 Runtime/Jupyter 管理服务
- 使用共享 Jupyter Server
- 在 MySQL 中维护 Runtime 实例和编辑会话租约;
- 创建、心跳和释放文件编辑锁;
- 创建短期 Jupyter 访问票据;
- 为 Nginx `auth_request` 校验票据并注入内部 Jupyter Token。
当前简化部署要求 Runtime 单副本运行。
+1 -2
View File
@@ -7,11 +7,10 @@ dependencies = [
"fastapi==0.116.1",
"uvicorn[standard]==0.35.0",
"httpx==0.28.1",
"redis==5.2.1",
]
[tool.uv.sources]
common = { workspace = true }
common = { path = "../common" }
[build-system]
requires = ["hatchling"]
+63 -183
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import secrets
@@ -23,8 +22,7 @@ from fastapi import (
Response,
status,
)
from redis.asyncio import Redis
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from common.db import create_database_engine, create_session_factory
@@ -39,13 +37,6 @@ from common.db.models import (
)
from common.ids import new_ulid
from common.service_app import create_service_app
from runtime.redis_lock import (
acquire as redis_acquire,
current as redis_current,
heartbeat as redis_heartbeat,
lock_key,
release as redis_release,
)
from runtime.providers.shared_jupyter import (
RuntimeProviderError,
SharedJupyterAdapter,
@@ -140,7 +131,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
os.getenv("FILE_LOCK_TTL_MS", "45000")
)
app.state.file_lock_enabled = (
os.getenv("FILE_LOCK_ENABLED", "true").strip().lower()
os.getenv("FILE_LOCK_ENABLED", "false").strip().lower()
not in {"0", "false", "no", "off"}
)
if not 5_000 <= app.state.file_lock_ttl_ms <= 300_000:
@@ -152,13 +143,8 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
raise RuntimeError(
"JUPYTER_TICKET_TTL_SECONDS must be between 30 and 300"
)
app.state.redis_client = Redis(
host=os.getenv("REDIS_HOST", "redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD"),
decode_responses=True,
)
await app.state.redis_client.ping()
app.state.jupyter_tickets: dict[str, dict[str, Any]] = {}
app.state.ticket_lock = asyncio.Lock()
jupyter_internal_url = os.getenv(
"JUPYTER_INTERNAL_URL",
"http://jupyter:8888/jupyter/",
@@ -195,7 +181,6 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
except asyncio.CancelledError:
pass
await app.state.jupyter_http_client.aclose()
await app.state.redis_client.aclose()
await engine.dispose()
@@ -351,7 +336,7 @@ async def acquire_file_lock(
edit_session_id = new_ulid()
raw_token = secrets.token_urlsafe(32)
digest = token_digest(raw_token)
key = lock_key(payload.workspace_id, payload.storage_object_id)
key = f"db-lock:{payload.workspace_id}:{payload.storage_object_id}"
if not request.app.state.file_lock_enabled:
key = f"{key}:session:{edit_session_id}"
@@ -362,40 +347,36 @@ async def acquire_file_lock(
user_id=payload.user_id,
)
storage_object = await editable_object(session, payload, role)
acquired = await redis_acquire(
request.app.state.redis_client,
key=key,
value={
"edit_session_id": edit_session_id,
"user_id": payload.user_id,
"display_name": user.display_name,
"token_hash": digest.hex(),
"acquired_at": utc_iso(now),
},
ttl_ms=ttl_ms,
if request.app.state.file_lock_enabled:
current = await session.scalar(
select(EditSessions)
.where(
EditSessions.workspace_id == payload.workspace_id,
EditSessions.storage_object_id == payload.storage_object_id,
EditSessions.session_status == "active",
)
if not acquired:
current, remaining_ms = await redis_current(
request.app.state.redis_client,
key,
)
lease_expires_at = utcnow() + timedelta(
milliseconds=max(remaining_ms, 0)
.order_by(EditSessions.started_at.desc())
.with_for_update()
)
if current is not None and current.expires_at > now:
editor = await session.get(Users, current.user_id)
raise lock_error(
status.HTTP_409_CONFLICT,
"FILE_LOCK_CONFLICT",
"文件正在被其他用户编辑",
retryable=True,
details={
"edit_session_id": (
current or {}
).get("edit_session_id"),
"editor_user_id": (current or {}).get("user_id"),
"editor_name": (current or {}).get("display_name"),
"lease_expires_at": utc_iso(lease_expires_at),
"edit_session_id": current.edit_session_id,
"editor_user_id": current.user_id,
"editor_name": editor.display_name if editor else current.user_id,
"lease_expires_at": utc_iso(current.expires_at),
},
)
if current is not None:
current.session_status = "expired"
current.ended_at = now
current.end_reason = "database_lease_expired"
runtime_session = None
try:
@@ -426,28 +407,13 @@ async def acquire_file_lock(
user_id=payload.user_id,
runtime_id=runtime_item.runtime_id,
jupyter_session_id=runtime_session.session_id,
redis_lock_key=key,
lock_key=key,
lock_token_hash=digest,
session_status="active",
started_at=now,
last_heartbeat_at=now,
expires_at=expires_at,
)
if request.app.state.file_lock_enabled:
await session.execute(
update(EditSessions)
.where(
EditSessions.workspace_id == payload.workspace_id,
EditSessions.storage_object_id
== payload.storage_object_id,
EditSessions.session_status == "active",
)
.values(
session_status="expired",
ended_at=now,
end_reason="redis_lease_expired",
)
)
session.add(item)
await session.commit()
except Exception as exc:
@@ -463,12 +429,6 @@ async def acquire_file_lock(
"failed to compensate Jupyter session creation",
exc_info=True,
)
await redis_release(
request.app.state.redis_client,
key=key,
edit_session_id=edit_session_id,
token_hash=digest.hex(),
)
if isinstance(exc, RuntimeProviderError):
raise lock_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -508,7 +468,7 @@ async def heartbeat_file_lock(
workspace_id=payload.workspace_id,
user_id=payload.user_id,
)
token_hash = verify_token(item, payload.lock_token)
verify_token(item, payload.lock_token)
if item.session_status != "active":
raise lock_error(
status.HTTP_409_CONFLICT,
@@ -516,19 +476,10 @@ async def heartbeat_file_lock(
"编辑锁已结束",
details={"session_status": item.session_status},
)
result = await redis_heartbeat(
request.app.state.redis_client,
key=item.redis_lock_key,
edit_session_id=item.edit_session_id,
token_hash=token_hash,
ttl_ms=ttl_ms,
)
if result != 1:
if item.expires_at <= now:
item.session_status = "expired"
item.ended_at = now
item.end_reason = (
"redis_lease_expired" if result == 0 else "lock_replaced"
)
item.end_reason = "database_lease_expired"
try:
await request.app.state.runtime_lifecycle.terminate_session(
item.runtime_id,
@@ -549,10 +500,7 @@ async def heartbeat_file_lock(
item.last_heartbeat_at = now
item.expires_at = now + timedelta(milliseconds=ttl_ms)
if item.runtime_id:
runtime_item = await session.get(
RuntimeInstances,
item.runtime_id,
)
runtime_item = await session.get(RuntimeInstances, item.runtime_id)
if runtime_item is not None:
request.app.state.runtime_lifecycle.touch(runtime_item)
await session.commit()
@@ -576,24 +524,12 @@ async def release_file_lock(
workspace_id=payload.workspace_id,
user_id=payload.user_id,
)
token_hash = verify_token(item, payload.lock_token)
verify_token(item, payload.lock_token)
if item.session_status != "active":
return {"data": session_payload(item), "meta": {"reused": True}}
result = await redis_release(
request.app.state.redis_client,
key=item.redis_lock_key,
edit_session_id=item.edit_session_id,
token_hash=token_hash,
)
item.ended_at = now
if result == 1:
item.session_status = "closed"
item.end_reason = "client_release"
else:
item.session_status = "expired"
item.end_reason = (
"redis_lease_expired" if result == 0 else "lock_replaced"
)
try:
await request.app.state.runtime_lifecycle.terminate_session(
item.runtime_id,
@@ -628,25 +564,7 @@ async def create_jupyter_access_ticket(
user_id=payload.user_id,
)
token_hash = verify_token(item, payload.lock_token)
if item.session_status != "active":
raise lock_error(
status.HTTP_409_CONFLICT,
"FILE_LOCK_NOT_ACTIVE",
"编辑锁已结束",
details={"session_status": item.session_status},
)
current, remaining_ms = await redis_current(
request.app.state.redis_client,
item.redis_lock_key,
)
if (
not current
or remaining_ms <= 0
or current.get("edit_session_id") != item.edit_session_id
or current.get("user_id") != item.user_id
or current.get("token_hash") != token_hash
):
if item.session_status != "active" or item.expires_at <= now:
raise lock_error(
status.HTTP_409_CONFLICT,
"FILE_LOCK_EXPIRED",
@@ -655,10 +573,7 @@ async def create_jupyter_access_ticket(
)
runtime_item = await session.get(RuntimeInstances, item.runtime_id)
if (
runtime_item is None
or runtime_item.actual_state != "running"
):
if runtime_item is None or runtime_item.actual_state != "running":
raise lock_error(
status.HTTP_409_CONFLICT,
"RUNTIME_NOT_RUNNING",
@@ -689,23 +604,13 @@ async def create_jupyter_access_ticket(
"edit_session_id": item.edit_session_id,
"runtime_id": item.runtime_id,
"jupyter_session_id": item.jupyter_session_id,
"redis_lock_key": item.redis_lock_key,
"lock_token_hash": token_hash,
"expires_at": utc_iso(expires_at),
"expires_at": expires_at,
}
stored = await request.app.state.redis_client.set(
jupyter_ticket_key(raw_ticket),
json.dumps(ticket_data, separators=(",", ":")),
ex=ttl_seconds,
nx=True,
)
if not stored:
raise lock_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"JUPYTER_TICKET_COLLISION",
"访问票据生成失败,请重试",
retryable=True,
)
async with request.app.state.ticket_lock:
request.app.state.jupyter_tickets[
jupyter_ticket_key(raw_ticket)
] = ticket_data
return {
"data": {
@@ -751,43 +656,30 @@ async def authorize_jupyter_proxy(
"缺少 Jupyter 访问票据",
)
raw_data = await request.app.state.redis_client.get(
jupyter_ticket_key(jupyter_access)
)
if not raw_data:
ticket_key = jupyter_ticket_key(jupyter_access)
async with request.app.state.ticket_lock:
ticket_data = request.app.state.jupyter_tickets.get(ticket_key)
if ticket_data and ticket_data["expires_at"] <= utcnow():
request.app.state.jupyter_tickets.pop(ticket_key, None)
ticket_data = None
if not ticket_data:
raise lock_error(
status.HTTP_401_UNAUTHORIZED,
"JUPYTER_TICKET_EXPIRED",
"Jupyter 访问票据无效或已过期",
)
try:
ticket_data = json.loads(raw_data)
except (TypeError, ValueError):
await request.app.state.redis_client.delete(
jupyter_ticket_key(jupyter_access)
)
raise lock_error(
status.HTTP_401_UNAUTHORIZED,
"JUPYTER_TICKET_INVALID",
"Jupyter 访问票据无效",
)
current, remaining_ms = await redis_current(
request.app.state.redis_client,
ticket_data["redis_lock_key"],
)
async with request.app.state.session_factory() as session:
item = await session.get(EditSessions, ticket_data["edit_session_id"])
if (
not current
or remaining_ms <= 0
or current.get("edit_session_id")
!= ticket_data["edit_session_id"]
or current.get("user_id") != ticket_data["user_id"]
or current.get("token_hash")
!= ticket_data["lock_token_hash"]
item is None
or item.session_status != "active"
or item.expires_at <= utcnow()
or item.user_id != ticket_data["user_id"]
or item.lock_token_hash.hex() != ticket_data["lock_token_hash"]
):
await request.app.state.redis_client.delete(
jupyter_ticket_key(jupyter_access)
)
async with request.app.state.ticket_lock:
request.app.state.jupyter_tickets.pop(ticket_key, None)
raise lock_error(
status.HTTP_403_FORBIDDEN,
"JUPYTER_EDIT_SESSION_INACTIVE",
@@ -823,20 +715,6 @@ async def reconcile_expired_edit_sessions(app_state: Any) -> None:
)
).all()
for item in items:
current, remaining_ms = await redis_current(
app_state.state.redis_client,
item.redis_lock_key,
)
if (
current
and current.get("edit_session_id")
== item.edit_session_id
and remaining_ms > 0
):
item.expires_at = now + timedelta(
milliseconds=remaining_ms
)
continue
try:
await (
app_state.state.runtime_lifecycle
@@ -852,8 +730,16 @@ async def reconcile_expired_edit_sessions(app_state: Any) -> None:
)
item.session_status = "expired"
item.ended_at = now
item.end_reason = "redis_lease_expired"
item.end_reason = "database_lease_expired"
await session.commit()
async with app_state.state.ticket_lock:
expired_keys = [
key
for key, value in app_state.state.jupyter_tickets.items()
if value["expires_at"] <= now
]
for key in expired_keys:
app_state.state.jupyter_tickets.pop(key, None)
except asyncio.CancelledError:
raise
except Exception:
@@ -1011,12 +897,6 @@ async def stop_runtime(
)
).all()
for edit_session in active_sessions:
await redis_release(
request.app.state.redis_client,
key=edit_session.redis_lock_key,
edit_session_id=edit_session.edit_session_id,
token_hash=edit_session.lock_token_hash.hex(),
)
try:
await request.app.state.runtime_lifecycle.terminate_session(
runtime_id,
+3 -4
View File
@@ -1,12 +1,11 @@
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
COPY pyproject.toml uv.lock ./
COPY common ./common
COPY schedule ./schedule
RUN uv sync --frozen --no-dev --no-editable --package schedule
RUN uv pip install --system ./common ./schedule
EXPOSE 8000
CMD ["uv", "run", "--frozen", "--package", "schedule", "uvicorn", "schedule.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "schedule.main:app", "--host", "0.0.0.0", "--port", "8000"]
+8 -3
View File
@@ -1,4 +1,9 @@
# Schedule
# Schedule Executor
独立调度执行服务。负责 Outbox/Redis Streams 轮询、DAG 节点派发、稳定版本
执行、重试、状态推进以及日志和结果回写。
独立调度执行服务,内置 APScheduler。
- Cron 任务持久化到 MySQL 的 `apscheduler_jobs` 表;
- FastAPI Backend 创建运行记录和 Outbox 事件后,通过 HTTP 尝试立即推送;
- HTTP 推送失败时,Executor 继续轮询 MySQL `outbox_events`,保证任务不会丢失;
- Executor 负责 DAG 节点派发、稳定版本执行、重试、状态推进和结果回写;
- 不依赖 Redis,MySQL 是调度状态与幂等状态的唯一权威。
+3 -2
View File
@@ -7,14 +7,15 @@ dependencies = [
"fastapi==0.116.1",
"uvicorn[standard]==0.35.0",
"httpx==0.28.1",
"redis==5.2.1",
"apscheduler==3.11.3",
"pymysql==1.2.0",
"nbclient==0.10.2",
"nbformat==5.10.4",
"ipykernel==6.29.5",
]
[tool.uv.sources]
common = { workspace = true }
common = { path = "../common" }
[build-system]
requires = ["hatchling"]
+35 -9
View File
@@ -1,38 +1,52 @@
from __future__ import annotations
import os
import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator
from fastapi import Depends, Header, HTTPException, Request, status
from common.db import create_database_engine, create_session_factory
from common.service_app import create_service_app
from schedule.service import (
SchedulerService,
build_object_store,
build_redis_client,
build_storage_http_client,
)
from schedule.storage_client import SchedulerStorageClient
def verify_internal_service(
x_service_token: str = Header(alias="X-Service-Token"),
) -> None:
expected = os.environ.get("INTERNAL_SERVICE_TOKEN", "")
if not expected or not secrets.compare_digest(expected, x_service_token):
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"invalid internal service identity",
)
@asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]:
engine = create_database_engine(os.environ["DATABASE_URL"])
database_url = os.environ["DATABASE_URL"]
engine = create_database_engine(database_url)
session_factory = create_session_factory(engine)
redis = build_redis_client()
storage_http_client = build_storage_http_client()
backend_http_client = build_storage_http_client()
service = SchedulerService(
session_factory=session_factory,
redis=redis,
object_store=build_object_store(),
storage_client=SchedulerStorageClient(
storage_http_client,
backend_http_client,
os.environ["INTERNAL_SERVICE_TOKEN"],
),
backend_http_client=backend_http_client,
workspace_root=Path(
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
),
database_url=database_url,
)
app.state.scheduler_service = service
await service.start()
@@ -40,12 +54,24 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
yield
finally:
await service.close()
await storage_http_client.aclose()
await redis.aclose()
await backend_http_client.aclose()
await engine.dispose()
app = create_service_app(
os.getenv("SERVICE_NAME", "scheduler-worker"),
os.getenv("SERVICE_NAME", "schedule-executor"),
lifespan=lifespan,
)
@app.post(
"/internal/v1/runs/{run_id}/dispatch",
dependencies=[Depends(verify_internal_service)],
)
async def dispatch_run(run_id: str, request: Request) -> dict[str, Any]:
processed = await request.app.state.scheduler_service.dispatch_run(run_id)
return {
"status": "accepted",
"run_id": run_id,
"processed_events": processed,
}
+174 -155
View File
@@ -5,18 +5,18 @@ import hashlib
import json
import logging
import os
import socket
import traceback
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import timedelta
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import boto3
import httpx
from redis.asyncio import Redis
from redis.exceptions import ResponseError
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -30,12 +30,7 @@ from common.db.models import (
Versions,
Workspaces,
)
from common.eventing import (
STREAM_BY_EVENT_TYPE,
add_outbox_event,
event_time,
utcnow,
)
from common.eventing import add_outbox_event, event_time, utcnow
from common.ids import new_ulid
from common.db.session import session_scope
from schedule.execution import ExecutionResult, execute_artifact
@@ -58,203 +53,236 @@ TERMINAL_RUN_STATES = {
"timed_out",
}
_ACTIVE_SERVICE: "SchedulerService | None" = None
def _sync_database_url(value: str) -> str:
return value.replace("mysql+asyncmy://", "mysql+pymysql://", 1)
def _naive_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).replace(tzinfo=None)
async def run_scheduled_job(schedule_id: str) -> None:
service = _ACTIVE_SERVICE
if service is None:
LOGGER.warning("scheduler job skipped because service is not ready")
return
await service.trigger_schedule(schedule_id)
class SchedulerService:
def __init__(
self,
*,
session_factory: async_sessionmaker[AsyncSession],
redis: Redis,
object_store: Any,
storage_client: SchedulerStorageClient,
backend_http_client: httpx.AsyncClient,
workspace_root: Path,
database_url: str,
) -> None:
self.session_factory = session_factory
self.redis = redis
self.object_store = object_store
self.storage_client = storage_client
self.backend_http_client = backend_http_client
self.workspace_root = workspace_root
self.consumer_name = (
os.getenv("SCHEDULER_CONSUMER_NAME")
or f"{socket.gethostname()}-{os.getpid()}"
)
self.tasks: list[asyncio.Task[Any]] = []
self.dispatch_lock = asyncio.Lock()
self.scheduler = AsyncIOScheduler(
jobstores={
"default": SQLAlchemyJobStore(
url=_sync_database_url(database_url),
tablename="apscheduler_jobs",
)
},
timezone=UTC,
)
async def start(self) -> None:
await self._ensure_group(
"stream:scheduler:commands",
"schedule-orchestrator",
)
await self._ensure_group("stream:jobs:execute", "job-workers")
await self._ensure_group("stream:jobs:results", "schedule-results")
global _ACTIVE_SERVICE
_ACTIVE_SERVICE = self
self.scheduler.start()
await self._sync_cron_jobs()
self.tasks = [
asyncio.create_task(
self._outbox_loop(),
name="scheduler-outbox-publisher",
self._database_event_loop(),
name="scheduler-database-events",
),
asyncio.create_task(
self._consumer_loop(
"stream:scheduler:commands",
"schedule-orchestrator",
self._handle_run_requested,
),
name="schedule-orchestrator",
),
asyncio.create_task(
self._consumer_loop(
"stream:jobs:execute",
"job-workers",
self._handle_node_execute,
),
name="job-worker",
),
asyncio.create_task(
self._consumer_loop(
"stream:jobs:results",
"schedule-results",
self._handle_node_finished,
),
name="schedule-results",
self._schedule_sync_loop(),
name="scheduler-cron-sync",
),
]
async def close(self) -> None:
global _ACTIVE_SERVICE
for task in self.tasks:
task.cancel()
for task in self.tasks:
with suppress(asyncio.CancelledError):
await task
self.tasks.clear()
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
_ACTIVE_SERVICE = None
async def _ensure_group(self, stream: str, group: str) -> None:
try:
await self.redis.xgroup_create(
stream,
group,
id="0-0",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise
async def _outbox_loop(self) -> None:
async def _database_event_loop(self) -> None:
while True:
try:
published = await self._publish_outbox_batch()
if not published:
await asyncio.sleep(0.35)
processed = await self.process_pending_events(limit=20)
if not processed:
await asyncio.sleep(0.25)
except asyncio.CancelledError:
raise
except Exception:
LOGGER.exception("outbox publisher iteration failed")
LOGGER.exception("database event loop failed")
await asyncio.sleep(1)
async def _publish_outbox_batch(self) -> int:
now = utcnow()
async def process_pending_events(
self,
*,
limit: int = 20,
aggregate_id: str | None = None,
) -> int:
async with self.dispatch_lock:
async with session_scope(self.session_factory) as session:
events = list(
(
await session.scalars(
statement = (
select(OutboxEvents)
.where(
OutboxEvents.event_status == "pending",
OutboxEvents.available_at <= now,
OutboxEvents.available_at <= utcnow(),
)
.order_by(OutboxEvents.created_at)
.limit(20)
.with_for_update(skip_locked=True)
.limit(limit)
)
).all()
if aggregate_id:
statement = statement.where(
OutboxEvents.aggregate_id == aggregate_id
)
events = list((await session.scalars(statement)).all())
for item in events:
stream = STREAM_BY_EVENT_TYPE.get(item.event_type)
if stream is None:
item.event_status = "failed"
item.last_error = f"unsupported event type: {item.event_type}"
continue
try:
await self.redis.xadd(
stream,
{
"event": json.dumps(
item.payload_json,
ensure_ascii=False,
separators=(",", ":"),
)
},
)
await self._process_outbox_event(item)
item.event_status = "published"
item.published_at = utcnow()
item.last_error = None
except Exception as exc:
item.retry_count += 1
item.last_error = str(exc)[:2000]
raise
if item.retry_count >= 5:
item.event_status = "failed"
else:
item.available_at = utcnow() + timedelta(
seconds=min(30, 2 ** item.retry_count)
)
LOGGER.exception(
"failed to process database event %s",
item.event_id,
)
return len(events)
async def _consumer_loop(
self,
stream: str,
group: str,
handler: Callable[[dict[str, Any], str], Awaitable[None]],
) -> None:
async def _process_outbox_event(self, item: OutboxEvents) -> None:
handlers = {
"schedule.run.requested": self._handle_run_requested,
"job.node.execute": self._handle_node_execute,
"job.node.finished": self._handle_node_finished,
}
handler = handlers.get(item.event_type)
if handler is None:
raise ValueError(f"unsupported event type: {item.event_type}")
await handler(item.payload_json, f"mysql:{item.event_id}")
async def dispatch_run(self, run_id: str) -> int:
return await self.process_pending_events(
limit=50,
aggregate_id=run_id,
)
async def _schedule_sync_loop(self) -> None:
while True:
try:
messages = await self.redis.xreadgroup(
group,
self.consumer_name,
{stream: ">"},
count=5,
block=1000,
)
entries: list[tuple[str, dict[str, str]]] = []
for _, stream_messages in messages:
entries.extend(stream_messages)
if not entries:
claimed = await self.redis.xautoclaim(
stream,
group,
self.consumer_name,
min_idle_time=10_000,
start_id="0-0",
count=5,
)
if len(claimed) >= 2:
entries.extend(claimed[1])
for message_id, fields in entries:
try:
raw = fields.get("event")
if not raw:
raise ValueError("stream message has no event field")
event = json.loads(raw)
except asyncio.CancelledError:
raise
except (ValueError, TypeError, json.JSONDecodeError):
LOGGER.exception(
"discarding malformed message %s from %s",
message_id,
stream,
)
await self.redis.xack(stream, group, message_id)
continue
try:
await handler(event, message_id)
await self._sync_cron_jobs()
except asyncio.CancelledError:
raise
except Exception:
LOGGER.exception(
"consumer %s failed for message %s",
group,
message_id,
LOGGER.exception("cron job synchronization failed")
await asyncio.sleep(5)
async def _sync_cron_jobs(self) -> None:
async with session_scope(self.session_factory) as session:
schedules = list(
(
await session.scalars(
select(Schedules).where(
Schedules.deleted_at.is_(None),
Schedules.enabled == 1,
Schedules.trigger_type == "cron",
Schedules.cron_expression.is_not(None),
)
)
).all()
)
active_job_ids: set[str] = set()
for item in schedules:
job_id = f"schedule:{item.schedule_id}"
active_job_ids.add(job_id)
expression = (item.cron_expression or "").strip()
trigger = CronTrigger.from_crontab(
expression,
timezone=ZoneInfo(item.timezone),
)
job = self.scheduler.add_job(
run_scheduled_job,
trigger=trigger,
args=[item.schedule_id],
id=job_id,
replace_existing=True,
coalesce=True,
max_instances=max(1, item.max_concurrency),
misfire_grace_time=60,
)
item.next_run_at = _naive_utc(job.next_run_time)
for job in self.scheduler.get_jobs():
if job.id.startswith("schedule:") and job.id not in active_job_ids:
self.scheduler.remove_job(job.id)
async def trigger_schedule(self, schedule_id: str) -> None:
async with self.session_factory() as session:
item = await session.get(Schedules, schedule_id)
if (
item is None
or item.deleted_at is not None
or not item.enabled
or item.trigger_type != "cron"
):
return
user_id = item.created_by
workspace_id = item.workspace_id
now = datetime.now(UTC)
idempotency_key = (
f"cron:{schedule_id}:{now.strftime('%Y%m%d%H%M')}"
)
response = await self.backend_http_client.post(
f"/api/v1/schedules/{schedule_id}/run",
headers={
"X-User-ID": user_id,
"X-Workspace-ID": workspace_id,
"X-Request-ID": new_ulid(),
"Idempotency-Key": idempotency_key,
},
json={"reason": "cron"},
)
if response.is_error:
raise RuntimeError(
f"backend rejected cron run: {response.status_code} "
f"{response.text[:500]}"
)
continue
await self.redis.xack(stream, group, message_id)
except asyncio.CancelledError:
raise
except Exception:
LOGGER.exception("consumer loop %s failed", group)
await asyncio.sleep(1)
async def _start_inbox(
self,
@@ -874,15 +902,6 @@ class SchedulerService:
self._finish_inbox(inbox)
def build_redis_client() -> Redis:
return Redis(
host=os.getenv("REDIS_HOST", "redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD") or None,
decode_responses=True,
)
def build_object_store() -> Any:
return boto3.client(
"s3",
@@ -898,6 +917,6 @@ def build_object_store() -> Any:
def build_storage_http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=os.getenv("STORAGE_API_URL", "http://storage_api:8000"),
base_url=os.getenv("BACKEND_API_URL", "http://backend:8000"),
timeout=httpx.Timeout(60.0),
)
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = "Stop"
Set-Location (Split-Path $PSScriptRoot -Parent)
docker compose logs -f backend runtime schedule gateway
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = "Stop"
Set-Location (Split-Path $PSScriptRoot -Parent)
if (-not (Test-Path ".env")) {
Copy-Item ".env.example" ".env"
Write-Host "已从 .env.example 创建 .env,请按需修改密码。"
}
docker compose config | Out-Null
docker compose up -d --build
docker compose ps
Write-Host "访问地址:http://127.0.0.1:8081"
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = "Stop"
Set-Location (Split-Path $PSScriptRoot -Parent)
docker compose down