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
+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"]
+81 -201
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 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,