重构模型平台前后端并移除Redis依赖

This commit is contained in:
Winnie
2026-07-30 19:03:00 +08:00
commit c74b2abb48
172 changed files with 40825 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
+1
View File
@@ -0,0 +1 @@
3.12
+12
View File
@@ -0,0 +1,12 @@
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 common ./common
COPY contracts ./contracts
COPY runtime ./runtime
RUN uv pip install --system ./common ./runtime
EXPOSE 8000
CMD ["uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"]
+11
View File
@@ -0,0 +1,11 @@
# Runtime
独立 Runtime/Jupyter 管理服务:
- 使用共享 Jupyter Server
- 在 MySQL 中维护 Runtime 实例和编辑会话租约;
- 创建、心跳和释放文件编辑锁;
- 创建短期 Jupyter 访问票据;
- 为 Nginx `auth_request` 校验票据并注入内部 Jupyter Token。
当前简化部署要求 Runtime 单副本运行。
+20
View File
@@ -0,0 +1,20 @@
[project]
name = "runtime"
version = "0.2.0"
requires-python = ">=3.12"
dependencies = [
"common",
"fastapi==0.116.1",
"uvicorn[standard]==0.35.0",
"httpx==0.28.1",
]
[tool.uv.sources]
common = { path = "../common" }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/runtime"]
+1
View File
@@ -0,0 +1 @@
"""Runtime Manager application."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Runtime provider implementations."""
@@ -0,0 +1,203 @@
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from pathlib import PurePosixPath
from urllib.parse import quote
import httpx
from contracts.runtime.runtime_adapter import (
CreateSessionRequest,
EnsureRuntimeRequest,
RuntimeEndpoint,
RuntimeHealth,
RuntimeSession,
)
class RuntimeProviderError(RuntimeError):
pass
class SharedJupyterAdapter:
"""Compose provider backed by one internal Jupyter Server.
Runtime rows are scoped to a Workspace. Each Notebook has its own
Jupyter Session and Kernel inside that server. Replacing this class with
a Docker or Kubernetes Workspace provider does not change the contract.
"""
provider_name = "process"
runtime_ref = "compose:jupyter"
def __init__(
self,
client: httpx.AsyncClient,
*,
internal_url: str,
proxy_base_path: str,
) -> None:
self.client = client
self.internal_url = internal_url.rstrip("/") + "/"
self.proxy_base_path = "/" + proxy_base_path.strip("/") + "/"
async def _request(
self,
method: str,
path: str,
*,
payload: dict | None = None,
allow_not_found: bool = False,
) -> httpx.Response:
try:
response = await self.client.request(method, path, json=payload)
except httpx.RequestError as exc:
raise RuntimeProviderError(
f"Jupyter request failed: {type(exc).__name__}"
) from exc
if allow_not_found and response.status_code == 404:
return response
if response.is_error:
raise RuntimeProviderError(
f"Jupyter returned HTTP {response.status_code}"
)
return response
async def ensure_running(
self,
request: EnsureRuntimeRequest,
) -> RuntimeEndpoint:
health = await self.health(request.runtime_id)
if not health.healthy:
raise RuntimeProviderError(
health.detail or "Jupyter is not healthy"
)
return RuntimeEndpoint(
runtime_id=request.runtime_id,
runtime_type="jupyter",
provider=self.provider_name,
runtime_ref=self.runtime_ref,
internal_url=self.internal_url,
proxy_base_path=self.proxy_base_path,
)
async def stop(self, runtime_id: str, reason: str) -> None:
# Compose keeps the shared infrastructure process alive. Stopping a
# logical Runtime terminates its sessions and updates MySQL state.
return None
async def restart(self, runtime_id: str) -> RuntimeEndpoint:
health = await self.health(runtime_id)
if not health.healthy:
raise RuntimeProviderError(
health.detail or "Jupyter is not healthy"
)
return RuntimeEndpoint(
runtime_id=runtime_id,
runtime_type="jupyter",
provider=self.provider_name,
runtime_ref=self.runtime_ref,
internal_url=self.internal_url,
proxy_base_path=self.proxy_base_path,
)
async def health(self, runtime_id: str) -> RuntimeHealth:
try:
await self._request("GET", "api/status")
except RuntimeProviderError as exc:
return RuntimeHealth(
runtime_id=runtime_id,
healthy=False,
checked_at=datetime.now(UTC),
detail=str(exc),
)
return RuntimeHealth(
runtime_id=runtime_id,
healthy=True,
checked_at=datetime.now(UTC),
)
@staticmethod
def _session_path(request: CreateSessionRequest) -> str:
relative = PurePosixPath(request.relative_path.replace("\\", "/"))
if (
relative.is_absolute()
or not relative.parts
or any(part in {"", ".", ".."} for part in relative.parts)
):
raise RuntimeProviderError("invalid Jupyter relative path")
return PurePosixPath(
request.workspace_code,
*relative.parts,
).as_posix()
async def create_session(
self,
request: CreateSessionRequest,
) -> RuntimeSession:
session_path = self._session_path(request)
encoded_path = quote(session_path, safe="/")
await self._request("GET", f"api/contents/{encoded_path}")
if session_path.lower().endswith(".ipynb"):
jupyter_url = (
f"{self.proxy_base_path}doc/tree/{encoded_path}"
)
else:
jupyter_url = (
f"{self.proxy_base_path}lab/tree/{encoded_path}"
)
if not session_path.lower().endswith(".ipynb"):
logical_id = hashlib.sha256(
f"{request.runtime_id}:{session_path}".encode("utf-8")
).hexdigest()[:32]
return RuntimeSession(
runtime_id=request.runtime_id,
session_id=f"file:{logical_id}",
jupyter_url=jupyter_url,
reused=True,
)
sessions_response = await self._request("GET", "api/sessions")
sessions = sessions_response.json()
for item in sessions:
if item.get("path") == session_path:
return RuntimeSession(
runtime_id=request.runtime_id,
session_id=str(item["id"]),
jupyter_url=jupyter_url,
reused=True,
)
created = (
await self._request(
"POST",
"api/sessions",
payload={
"path": session_path,
"name": "",
"type": "notebook",
"kernel": {"name": "python3"},
},
)
).json()
return RuntimeSession(
runtime_id=request.runtime_id,
session_id=str(created["id"]),
jupyter_url=jupyter_url,
reused=False,
)
async def terminate_session(
self,
runtime_id: str,
session_id: str,
) -> None:
if session_id.startswith("file:"):
return
await self._request(
"DELETE",
f"api/sessions/{quote(session_id, safe='')}",
allow_not_found=True,
)
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from common.db.models import (
RuntimeInstances,
WorkspaceOperations,
Workspaces,
)
from common.ids import new_ulid
from contracts.runtime.runtime_adapter import (
CreateSessionRequest,
RuntimeAdapter,
RuntimeHealth,
RuntimeSession,
EnsureRuntimeRequest,
)
def utcnow() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
class RuntimeLifecycle:
def __init__(
self,
adapter: RuntimeAdapter,
*,
lease_seconds: int,
) -> None:
self.adapter = adapter
self.lease_seconds = lease_seconds
def _lease_expiry(self, now: datetime) -> datetime:
return now + timedelta(seconds=self.lease_seconds)
async def _record_operation(
self,
session: AsyncSession,
*,
workspace_id: str,
runtime_id: str,
user_id: str,
operation_type: str,
request_id: str | None,
status: str = "succeeded",
error_code: str | None = None,
error_message: str | None = None,
) -> None:
now = utcnow()
operation_request_id = (
f"runtime:{operation_type}:{request_id}"
if request_id
else None
)
if operation_request_id:
existing = await session.scalar(
select(WorkspaceOperations).where(
WorkspaceOperations.request_id == operation_request_id
)
)
if existing is not None:
return
session.add(
WorkspaceOperations(
operation_id=new_ulid(),
workspace_id=workspace_id,
runtime_id=runtime_id,
operation_type=operation_type,
operation_status=status,
state_version=1,
request_id=operation_request_id,
requested_by=user_id,
started_at=now,
finished_at=now,
error_code=error_code,
error_message=error_message,
)
)
async def ensure_running(
self,
session: AsyncSession,
*,
workspace: Workspaces,
user_id: str,
request_id: str | None,
) -> tuple[RuntimeInstances, bool]:
# Serializes ensure_running for one Workspace across stateless Runtime
# Manager replicas. Provider calls remain behind the adapter boundary.
await session.execute(
select(Workspaces.workspace_id)
.where(Workspaces.workspace_id == workspace.workspace_id)
.with_for_update()
)
now = utcnow()
existing = await session.scalar(
select(RuntimeInstances)
.where(
RuntimeInstances.workspace_id == workspace.workspace_id,
RuntimeInstances.runtime_type == "jupyter",
RuntimeInstances.desired_state == "running",
RuntimeInstances.actual_state.in_(
["provisioning", "starting", "running", "unhealthy"]
),
)
.order_by(RuntimeInstances.created_at.desc())
)
if existing is not None:
# Jupyter Server is scoped to the Workspace. The user who first
# starts it is still recorded in started_by and operation audit,
# while owner_user_id=None identifies a shared Workspace Runtime.
existing.owner_user_id = None
health = await self.adapter.health(existing.runtime_id)
if health.healthy:
existing.actual_state = "running"
existing.last_heartbeat_at = now
existing.lease_expires_at = self._lease_expiry(now)
existing.state_version += 1
existing.error_message = None
await self._record_operation(
session,
workspace_id=workspace.workspace_id,
runtime_id=existing.runtime_id,
user_id=user_id,
operation_type="open",
request_id=request_id,
)
return existing, True
existing.actual_state = "unhealthy"
existing.state_version += 1
existing.error_message = health.detail
runtime_id = new_ulid()
endpoint = await self.adapter.ensure_running(
EnsureRuntimeRequest(
runtime_id=runtime_id,
workspace_id=workspace.workspace_id,
workspace_code=workspace.workspace_code,
owner_user_id=user_id,
)
)
item = RuntimeInstances(
runtime_id=runtime_id,
workspace_id=workspace.workspace_id,
owner_user_id=None,
runtime_type=endpoint.runtime_type,
runtime_provider=endpoint.provider,
runtime_ref=endpoint.runtime_ref,
host_node="compose",
internal_url=endpoint.internal_url,
proxy_base_path=endpoint.proxy_base_path,
desired_state="running",
actual_state="running",
state_version=1,
started_by=user_id,
started_at=now,
last_heartbeat_at=now,
lease_expires_at=self._lease_expiry(now),
)
session.add(item)
await session.flush()
await self._record_operation(
session,
workspace_id=workspace.workspace_id,
runtime_id=item.runtime_id,
user_id=user_id,
operation_type="start",
request_id=request_id,
)
return item, False
async def health(
self,
item: RuntimeInstances,
) -> RuntimeHealth:
health = await self.adapter.health(item.runtime_id)
now = utcnow()
item.last_heartbeat_at = now
item.actual_state = "running" if health.healthy else "unhealthy"
item.error_message = health.detail
if health.healthy:
item.lease_expires_at = self._lease_expiry(now)
item.state_version += 1
return health
async def stop(
self,
session: AsyncSession,
item: RuntimeInstances,
*,
user_id: str,
request_id: str | None,
reason: str,
) -> None:
if item.actual_state == "stopped":
return
item.desired_state = "stopped"
item.actual_state = "stopping"
item.state_version += 1
await self.adapter.stop(item.runtime_id, reason)
item.actual_state = "stopped"
item.stopped_at = utcnow()
item.lease_expires_at = None
item.state_version += 1
await self._record_operation(
session,
workspace_id=item.workspace_id,
runtime_id=item.runtime_id,
user_id=user_id,
operation_type="stop",
request_id=request_id,
)
async def restart(
self,
session: AsyncSession,
item: RuntimeInstances,
*,
user_id: str,
request_id: str | None,
) -> None:
item.desired_state = "running"
item.actual_state = "starting"
item.state_version += 1
endpoint = await self.adapter.restart(item.runtime_id)
now = utcnow()
item.runtime_ref = endpoint.runtime_ref
item.internal_url = endpoint.internal_url
item.proxy_base_path = endpoint.proxy_base_path
item.actual_state = "running"
item.started_at = item.started_at or now
item.last_heartbeat_at = now
item.lease_expires_at = self._lease_expiry(now)
item.stopped_at = None
item.error_message = None
item.state_version += 1
await self._record_operation(
session,
workspace_id=item.workspace_id,
runtime_id=item.runtime_id,
user_id=user_id,
operation_type="restart",
request_id=request_id,
)
async def create_session(
self,
item: RuntimeInstances,
*,
workspace_code: str,
relative_path: str,
) -> RuntimeSession:
result = await self.adapter.create_session(
CreateSessionRequest(
runtime_id=item.runtime_id,
workspace_code=workspace_code,
relative_path=relative_path,
)
)
now = utcnow()
item.last_heartbeat_at = now
item.lease_expires_at = self._lease_expiry(now)
item.state_version += 1
return result
def touch(self, item: RuntimeInstances) -> None:
now = utcnow()
item.last_heartbeat_at = now
item.lease_expires_at = self._lease_expiry(now)
item.state_version += 1
async def terminate_session(
self,
runtime_id: str | None,
session_id: str | None,
) -> None:
if runtime_id and session_id:
await self.adapter.terminate_session(runtime_id, session_id)
def runtime_payload(item: RuntimeInstances) -> dict[str, Any]:
def iso(value: datetime | None) -> str | None:
if value is None:
return None
return value.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
return {
"runtime_id": item.runtime_id,
"workspace_id": item.workspace_id,
"owner_user_id": item.owner_user_id,
"runtime_type": item.runtime_type,
"runtime_provider": item.runtime_provider,
"runtime_ref": item.runtime_ref,
"internal_url": item.internal_url,
"proxy_base_path": item.proxy_base_path,
"desired_state": item.desired_state,
"actual_state": item.actual_state,
"state_version": item.state_version,
"started_at": iso(item.started_at),
"last_heartbeat_at": iso(item.last_heartbeat_at),
"lease_expires_at": iso(item.lease_expires_at),
"stopped_at": iso(item.stopped_at),
"error_message": item.error_message,
}
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class AcquireFileLockRequest(StrictModel):
workspace_id: str = Field(min_length=26, max_length=26)
storage_object_id: str = Field(min_length=26, max_length=26)
user_id: str = Field(min_length=26, max_length=26)
request_id: str | None = Field(default=None, min_length=1, max_length=64)
class FileLockTokenRequest(StrictModel):
workspace_id: str = Field(min_length=26, max_length=26)
user_id: str = Field(min_length=26, max_length=26)
lock_token: str = Field(min_length=32, max_length=256)
class RuntimeIdentityRequest(StrictModel):
workspace_id: str = Field(min_length=26, max_length=26)
user_id: str = Field(min_length=26, max_length=26)
request_id: str | None = Field(default=None, min_length=1, max_length=64)
class EnsureRuntimeApiRequest(RuntimeIdentityRequest):
pass
class StopRuntimeApiRequest(RuntimeIdentityRequest):
reason: str = Field(default="client_request", min_length=1, max_length=64)
class CreateRuntimeSessionApiRequest(RuntimeIdentityRequest):
storage_object_id: str = Field(min_length=26, max_length=26)
relative_path: str = Field(min_length=1, max_length=1024)