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
+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,