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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user