重构模型平台前后端并移除Redis依赖
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Runtime
|
||||
|
||||
独立 Runtime/Jupyter 管理服务:
|
||||
|
||||
- 使用共享 Jupyter Server;
|
||||
- 在 MySQL 中维护 Runtime 实例和编辑会话租约;
|
||||
- 创建、心跳和释放文件编辑锁;
|
||||
- 创建短期 Jupyter 访问票据;
|
||||
- 为 Nginx `auth_request` 校验票据并注入内部 Jupyter Token。
|
||||
|
||||
当前简化部署要求 Runtime 单副本运行。
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user