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:
+2
-3
@@ -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
@@ -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 单副本运行。
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+81
-201
@@ -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 not acquired:
|
||||
current, remaining_ms = await redis_current(
|
||||
request.app.state.redis_client,
|
||||
key,
|
||||
)
|
||||
lease_expires_at = utcnow() + timedelta(
|
||||
milliseconds=max(remaining_ms, 0)
|
||||
)
|
||||
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),
|
||||
},
|
||||
|
||||
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",
|
||||
)
|
||||
.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.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"
|
||||
)
|
||||
item.session_status = "closed"
|
||||
item.end_reason = "client_release"
|
||||
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,48 +656,35 @@ 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"],
|
||||
)
|
||||
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"]
|
||||
):
|
||||
await request.app.state.redis_client.delete(
|
||||
jupyter_ticket_key(jupyter_access)
|
||||
)
|
||||
raise lock_error(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"JUPYTER_EDIT_SESSION_INACTIVE",
|
||||
"编辑会话已失效",
|
||||
)
|
||||
async with request.app.state.session_factory() as session:
|
||||
item = await session.get(EditSessions, ticket_data["edit_session_id"])
|
||||
if (
|
||||
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"]
|
||||
):
|
||||
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",
|
||||
"编辑会话已失效",
|
||||
)
|
||||
|
||||
return Response(
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user