refactor: remove local fs, add config class to common pacakge

This commit is contained in:
tao.chen
2026-07-31 12:46:31 +08:00
parent 872388775a
commit f288c90d37
18 changed files with 192 additions and 343 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ dependencies = [
] ]
[tool.uv.sources] [tool.uv.sources]
common = { path = "../common" } common = { workspace = true }
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
+2 -2
View File
@@ -4,7 +4,6 @@ import base64
import hashlib import hashlib
import hmac import hmac
import json import json
import os
import re import re
import time import time
from typing import Optional from typing import Optional
@@ -15,6 +14,7 @@ from sqlalchemy import select
from backend.dependencies import database_session from backend.dependencies import database_session
from backend.runtime_client import RuntimeClientError from backend.runtime_client import RuntimeClientError
from common.config import settings
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,7 +23,7 @@ router = APIRouter(tags=["jupyter"])
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-only-not-for-production") JWT_SECRET = settings.jwt_secret
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
+8 -19
View File
@@ -1,14 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx import httpx
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from common.config import settings
from common.db import create_database_engine, create_session_factory from common.db import create_database_engine, create_session_factory
from common.service_app import create_service_app from common.service_app import create_service_app
from common.storage import RustFSObjectStore from common.storage import RustFSObjectStore
@@ -25,27 +24,17 @@ from backend.storage_client import StorageClient
@asynccontextmanager @asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]: async def lifespan(app: Any) -> AsyncIterator[None]:
engine = create_database_engine(os.environ["DATABASE_URL"]) engine = create_database_engine(settings.database_url)
app.state.session_factory = create_session_factory(engine) app.state.session_factory = create_session_factory(engine)
workspace_root = Path(
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
)
workspace_root.mkdir(parents=True, exist_ok=True)
# Storage API is now part of the backend process. Platform routers keep # Storage API is now part of the backend process. Platform routers keep
# their existing client contract, but calls are dispatched in-process. # their existing client contract, but calls are dispatched in-process.
app.state.object_store = RustFSObjectStore( app.state.object_store = RustFSObjectStore(
internal_endpoint=os.getenv( internal_endpoint=settings.rustfs_endpoint,
"RUSTFS_INTERNAL_ENDPOINT", access_key=settings.rustfs_access_key,
"http://rustfs:9000", secret_key=settings.rustfs_secret_key,
),
access_key=os.environ["RUSTFS_ACCESS_KEY"],
secret_key=os.environ["RUSTFS_SECRET_KEY"],
)
app.state.default_bucket = os.getenv(
"RUSTFS_DEFAULT_BUCKET",
"model-platform",
) )
app.state.default_bucket = settings.rustfs_workspace_bucket
await asyncio.to_thread( await asyncio.to_thread(
app.state.object_store.ensure_bucket, app.state.object_store.ensure_bucket,
app.state.default_bucket, app.state.default_bucket,
@@ -57,7 +46,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
) )
app.state.storage_client = StorageClient(storage_http_client) app.state.storage_client = StorageClient(storage_http_client)
runtime_http_client = httpx.AsyncClient( runtime_http_client = httpx.AsyncClient(
base_url=os.getenv("RUNTIME_API_URL", "http://runtime:8000"), base_url=settings.runtime_api_url,
timeout=httpx.Timeout(30.0), timeout=httpx.Timeout(30.0),
) )
app.state.runtime_client = RuntimeClient(runtime_http_client) app.state.runtime_client = RuntimeClient(runtime_http_client)
@@ -70,7 +59,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
app = create_service_app( app = create_service_app(
os.getenv("SERVICE_NAME", "backend"), settings.service_name,
lifespan=lifespan, lifespan=lifespan,
) )
app.include_router(jupyter_router) app.include_router(jupyter_router)
+13 -40
View File
@@ -1,43 +1,16 @@
from __future__ import annotations """Schedule executor HTTP-side dispatch is intentionally a no-op.
import logging Architecture V3.1 §2.3 documents two dispatch paths:
① Backend HTTP push to Schedule Executor (immediate runs)
② Schedule Executor polling MySQL Outbox (immediate + cron runs; contract)
import httpx We run path ② only. Path ① is an optimisation layered on top of ② and was
removed alongside the INTERNAL_SERVICE_TOKEN cleanup. Backend writes the
``schedule.run.requested`` Outbox event in the same transaction as the
``ScheduleRuns`` row, then commits; the executor picks it up on its next
poll. No additional auth / token / header is involved — the Outbox is the
single source of truth for run dispatch.
This module is kept as a docstring-only placeholder so future readers
LOGGER = logging.getLogger(__name__) (LLM and human) can grep for ``schedule_client`` and find the rationale.
"""
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
+3 -3
View File
@@ -293,10 +293,10 @@ async def run_schedule_now(
}, },
) )
await session.flush() await session.flush()
# Commit before the HTTP push so the executor can read the Outbox row. # Commit before yielding so the Outbox row is visible to the executor's
# The executor also polls MySQL, so a failed push does not lose the run. # next MySQL poll — the executor's _database_event_loop picks it up.
# We intentionally do NOT HTTP-push; see backend/schedule_client.py.
await session.commit() await session.commit()
await request.app.state.schedule_client.dispatch_run(run.run_id)
await session.refresh(run) await session.refresh(run)
return { return {
"request_id": context.request_id, "request_id": context.request_id,
+111 -118
View File
@@ -1,13 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib import hashlib
import json import json
import mimetypes import mimetypes
import os
import shutil
import tempfile
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path, PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
from fastapi import ( from fastapi import (
@@ -127,51 +125,6 @@ def validate_script_content(content: str, script_type: str) -> bytes:
return encoded return encoded
def workspace_target(
context: RequestContext,
relative_path: str,
) -> Path:
root = Path(
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
).resolve()
scoped_root = (root / context.workspace.workspace_code).resolve()
pure_path = PurePosixPath(relative_path)
target = (scoped_root / Path(*pure_path.parts)).resolve()
if scoped_root not in target.parents:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"script path escapes workspace root",
)
return target
def apply_workspace_permissions(path: Path, mode: int) -> None:
os.chmod(path, mode)
if os.name != "nt":
shared_gid = int(os.getenv("WORKSPACE_SHARED_GID", "100"))
os.chown(path, -1, shared_gid)
def atomic_write(target: Path, content: bytes) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
apply_workspace_permissions(target.parent, 0o2770)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{target.name}.",
suffix=".tmp",
dir=target.parent,
)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_name, target)
apply_workspace_permissions(target, 0o660)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
def script_payload( def script_payload(
script: Scripts, script: Scripts,
storage_object: StorageObjects | dict[str, Any], storage_object: StorageObjects | dict[str, Any],
@@ -310,7 +263,6 @@ async def create_script_record(
) if parent_path is None else normalize_user_path(parent_path) ) if parent_path is None else normalize_user_path(parent_path)
child_path = f"{folder}/{name}" if folder else name child_path = f"{folder}/{name}" if folder else name
relative_path = user_relative_path(context, child_path) relative_path = user_relative_path(context, child_path)
target = workspace_target(context, relative_path)
existing_script = await session.scalar( existing_script = await session.scalar(
select(Scripts) select(Scripts)
@@ -324,21 +276,23 @@ async def create_script_record(
Scripts.status == "active", Scripts.status == "active",
) )
) )
if existing_script is not None or target.exists(): if existing_script is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"a file with the same path already exists", "a file with the same path already exists",
) )
atomic_write(target, content) storage_data = await request.app.state.storage_client.create_server_object(
storage_data = await request.app.state.storage_client.register_workspace_object( workspace_id=context.workspace.workspace_id,
{ user_id=context.user.user_id,
"workspace_id": context.workspace.workspace_id, usage_type="working_copy",
"user_id": context.user.user_id, file_name=name,
"relative_path": relative_path, content_type=mimetypes.guess_type(name)[0]
"usage_type": "working_copy", or "application/octet-stream",
"visibility": visibility, content=content,
} visibility=visibility,
is_immutable=False,
idempotency_key=f"script:{context.workspace.workspace_id}:{relative_path}",
) )
object_id = storage_data["storage_object_id"] object_id = storage_data["storage_object_id"]
script = await session.scalar( script = await session.scalar(
@@ -453,34 +407,57 @@ async def upload_script(
@router.get("/api/v1/workspace-tree") @router.get("/api/v1/workspace-tree")
async def get_workspace_tree( async def get_workspace_tree(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
root = workspace_target(context, user_relative_path(context)) # Workspace object storage uses implicit directories (object key prefixes),
root.mkdir(parents=True, exist_ok=True) # so we derive the tree from ``StorageObjects.relative_path`` rather than
apply_workspace_permissions(root, 0o2770) # walking a local filesystem. Only paths that start with the user's
directories: list[dict[str, str]] = [] # scoped prefix (and that are currently active) contribute.
for current, names, _files in os.walk(root, followlinks=False): scoped_prefix = user_relative_path(context)
names[:] = sorted( if scoped_prefix:
name like_prefix = f"{scoped_prefix}%"
for name in names else:
if not name.startswith(".") like_prefix = "%"
and not (Path(current) / name).is_symlink()
rows = (
await session.execute(
select(StorageObjects.relative_path)
.where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
StorageObjects.relative_path.like(like_prefix),
) )
current_path = Path(current) )
if current_path == root: ).scalars().all()
directories: dict[str, dict[str, str]] = {}
for relative in rows:
if not relative:
continue continue
relative = current_path.relative_to(root).as_posix() # Strip the scoped prefix so the returned paths are workspace-local.
parent = PurePosixPath(relative).parent.as_posix() if scoped_prefix and relative.startswith(scoped_prefix + "/"):
directories.append( trimmed = relative[len(scoped_prefix) + 1 :]
elif relative == scoped_prefix:
continue
else:
trimmed = relative
# Materialise every ancestor directory of the file.
parts = trimmed.split("/")[:-1]
for index in range(1, len(parts) + 1):
directory_path = "/".join(parts[:index])
directories.setdefault(
directory_path,
{ {
"path": relative, "path": directory_path,
"name": current_path.name, "name": parts[index - 1],
"parent_path": "" if parent == "." else parent, "parent_path": "" if index == 1 else "/".join(parts[: index - 1]),
} },
) )
sorted_dirs = sorted(directories.values(), key=lambda item: item["path"])
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": {"directories": directories}, "data": {"directories": sorted_dirs},
"meta": {"directory_count": len(directories)}, "meta": {"directory_count": len(sorted_dirs)},
} }
@@ -491,31 +468,42 @@ async def get_workspace_tree(
async def create_workspace_directory( async def create_workspace_directory(
payload: CreateWorkspaceDirectoryRequest, payload: CreateWorkspaceDirectoryRequest,
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
name = safe_directory_name(payload.directory_name) name = safe_directory_name(payload.directory_name)
parent = normalize_user_path(payload.parent_path) parent = normalize_user_path(payload.parent_path)
child_path = f"{parent}/{name}" if parent else name child_path = f"{parent}/{name}" if parent else name
target = workspace_target(context, user_relative_path(context, child_path)) relative_path = user_relative_path(context, child_path)
parent_target = target.parent scoped_prefix = user_relative_path(context)
user_root = workspace_target(context, user_relative_path(context)) # Validate parent exists: there must be at least one StorageObject whose
if ( # relative_path is exactly the parent directory (or its prefix).
not parent_target.is_dir() if parent:
or ( existing_parent = await session.scalar(
parent_target != user_root select(StorageObjects.storage_object_id).where(
and user_root not in parent_target.parents StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
StorageObjects.relative_path.like(f"{scoped_prefix}%"),
) )
): )
if existing_parent is None:
raise HTTPException( raise HTTPException(
status.HTTP_404_NOT_FOUND, status.HTTP_404_NOT_FOUND,
"parent directory not found", "parent directory not found",
) )
if target.exists(): # RustFS has no real directory objects — the prefix is implicitly
# created when a file is uploaded. Conflict detection is best-effort.
existing = await session.scalar(
select(StorageObjects.storage_object_id).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.relative_path == relative_path,
StorageObjects.object_status == "available",
)
)
if existing is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"a file or directory with the same path already exists", "a file or directory with the same path already exists",
) )
target.mkdir(parents=False)
apply_workspace_permissions(target, 0o2770)
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": { "data": {
@@ -536,12 +524,6 @@ async def delete_workspace_directory(
) -> dict[str, Any]: ) -> dict[str, Any]:
directory_path = normalize_user_path(path, allow_empty=False) directory_path = normalize_user_path(path, allow_empty=False)
relative_prefix = user_relative_path(context, directory_path) relative_prefix = user_relative_path(context, directory_path)
target = workspace_target(context, relative_prefix)
if not target.is_dir() or target.is_symlink():
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"directory not found",
)
rows = ( rows = (
await session.execute( await session.execute(
@@ -570,7 +552,6 @@ async def delete_workspace_directory(
) )
script.status = "deleted" script.status = "deleted"
script.deleted_at = datetime.now(UTC).replace(tzinfo=None) script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
shutil.rmtree(target)
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": { "data": {
@@ -657,16 +638,21 @@ async def update_script(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"script has no workspace path", "script has no workspace path",
) )
target = workspace_target(context, storage_object.relative_path) storage_data = await request.app.state.storage_client.create_server_object(
atomic_write(target, content) workspace_id=context.workspace.workspace_id,
storage_data = await request.app.state.storage_client.register_workspace_object( user_id=script.owner_user_id,
{ usage_type="working_copy",
"workspace_id": context.workspace.workspace_id, file_name=storage_object.file_name or script.script_name,
"user_id": script.owner_user_id, content_type=storage_object.mime_type
"relative_path": storage_object.relative_path, or mimetypes.guess_type(script.script_name)[0]
"usage_type": "working_copy", or "application/octet-stream",
"visibility": script.visibility, content=content,
} visibility=script.visibility,
is_immutable=False,
idempotency_key=(
f"script-update:{script.script_id}:"
f"{hashlib.sha256(content).hexdigest()}"
),
) )
storage_object.content_hash = storage_data["content_hash"] storage_object.content_hash = storage_data["content_hash"]
storage_object.size_bytes = storage_data["size_bytes"] storage_object.size_bytes = storage_data["size_bytes"]
@@ -703,10 +689,6 @@ async def delete_script(
session, session,
[script.current_object_id], [script.current_object_id],
) )
if storage_object.relative_path:
target = workspace_target(context, storage_object.relative_path)
if target.is_file():
target.unlink()
await request.app.state.storage_client.delete_object( await request.app.state.storage_client.delete_object(
script.current_object_id script.current_object_id
) )
@@ -761,13 +743,24 @@ async def publish_version(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"script has no workspace path", "script has no workspace path",
) )
target = workspace_target(context, source_object.relative_path) if not source_object.bucket_name or not source_object.object_key:
if not target.is_file():
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"script working copy is missing", "script working copy is not stored in object storage",
) )
content = target.read_bytes()
def read_object_bytes() -> bytes:
response = request.app.state.object_store.get_object(
Bucket=source_object.bucket_name,
Key=source_object.object_key,
)
body = response["Body"]
try:
return body.read()
finally:
body.close()
content = await asyncio.to_thread(read_object_bytes)
content_hash = hashlib.sha256(content).hexdigest() content_hash = hashlib.sha256(content).hexdigest()
existing = await session.scalar( existing = await session.scalar(
select(Versions).where( select(Versions).where(
+13 -98
View File
@@ -5,17 +5,17 @@ import base64
import binascii import binascii
import hashlib import hashlib
import mimetypes import mimetypes
import os
import secrets import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path, PurePosixPath from pathlib import PurePosixPath
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.config import settings
from common.db import create_database_engine, create_session_factory, session_scope from common.db import create_database_engine, create_session_factory, session_scope
from common.db.models import ( from common.db.models import (
StorageObjects, StorageObjects,
@@ -30,7 +30,6 @@ from common.storage.schemas import (
CompleteUploadRequest, CompleteUploadRequest,
CreateUploadRequest, CreateUploadRequest,
DownloadUrlRequest, DownloadUrlRequest,
RegisterWorkspaceObjectRequest,
ServerObjectRequest) ServerObjectRequest)
@@ -83,18 +82,14 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]: async def lifespan(app: Any) -> AsyncIterator[None]:
database_url = os.environ["DATABASE_URL"] engine = create_database_engine(settings.database_url)
engine = create_database_engine(database_url)
app.state.session_factory = create_session_factory(engine) app.state.session_factory = create_session_factory(engine)
app.state.object_store = RustFSObjectStore( app.state.object_store = RustFSObjectStore(
internal_endpoint=os.getenv( internal_endpoint=settings.rustfs_endpoint,
"RUSTFS_INTERNAL_ENDPOINT", access_key=settings.rustfs_access_key,
"http://rustfs:9000"), secret_key=settings.rustfs_secret_key,
access_key=os.environ["RUSTFS_ACCESS_KEY"], )
secret_key=os.environ["RUSTFS_SECRET_KEY"]) app.state.default_bucket = settings.rustfs_workspace_bucket
app.state.default_bucket = os.getenv(
"RUSTFS_DEFAULT_BUCKET",
"model-platform")
await asyncio.to_thread( await asyncio.to_thread(
app.state.object_store.ensure_bucket, app.state.object_store.ensure_bucket,
app.state.default_bucket) app.state.default_bucket)
@@ -105,7 +100,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
app = create_service_app( app = create_service_app(
os.getenv("SERVICE_NAME", "storage-api"), settings.service_name,
lifespan=lifespan) lifespan=lifespan)
@@ -174,8 +169,11 @@ async def create_upload_record(
bucket_name = ( bucket_name = (
workspace.artifact_bucket or request.app.state.default_bucket workspace.artifact_bucket or request.app.state.default_bucket
) )
# Default layout: one top-level folder per workspace inside the
# ``workspaces`` bucket. ``bucket_name`` already encodes the workspace
# namespace, so the key starts with the workspace id directly.
object_key = ( object_key = (
f"workspaces/{payload.workspace_id}/" f"{payload.workspace_id}/"
f"{payload.usage_type}/{upload_id}/{file_name}" f"{payload.usage_type}/{upload_id}/{file_name}"
) )
upload = UploadSessions( upload = UploadSessions(
@@ -459,89 +457,6 @@ async def create_server_object(
return {"data": storage_payload(item), "meta": {"reused": False}} return {"data": storage_payload(item), "meta": {"reused": False}}
@app.post(
"/internal/v1/workspace-objects")
async def register_workspace_object(
payload: RegisterWorkspaceObjectRequest,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
workspace = await require_workspace_member(
session,
payload.workspace_id,
payload.user_id)
pure_path = PurePosixPath(payload.relative_path.replace("\\", "/"))
if (
pure_path.is_absolute()
or not pure_path.parts
or any(part in {"", ".", ".."} for part in pure_path.parts)
):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"invalid workspace relative_path")
relative_path = pure_path.as_posix()
workspace_root = Path(
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
).resolve()
scoped_root = (workspace_root / workspace.workspace_code).resolve()
target = (scoped_root / Path(*pure_path.parts)).resolve()
if scoped_root != target and scoped_root not in target.parents:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"workspace path escapes its root")
if not target.is_file():
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"workspace file does not exist")
content = await asyncio.to_thread(target.read_bytes)
content_hash = hashlib.sha256(content).hexdigest()
stat_result = target.stat()
path_digest = hash_bytes(relative_path)
item = await session.scalar(
select(StorageObjects).where(
StorageObjects.workspace_id == payload.workspace_id,
StorageObjects.storage_backend == "workspace_fs",
StorageObjects.path_hash == path_digest)
)
reused = item is not None
if item is None:
item = StorageObjects(
storage_object_id=new_ulid(),
workspace_id=payload.workspace_id,
owner_user_id=payload.user_id,
object_type="file",
usage_type=payload.usage_type,
storage_backend="workspace_fs",
relative_path=relative_path,
path_hash=path_digest,
storage_uri=target.as_uri(),
file_name=target.name,
visibility=payload.visibility,
is_immutable=0,
object_status="available",
created_by=payload.user_id)
session.add(item)
elif item.is_immutable:
raise HTTPException(
status.HTTP_409_CONFLICT,
"immutable workspace object cannot be updated")
elif item.owner_user_id != payload.user_id:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"workspace object belongs to another user")
item.usage_type = payload.usage_type
item.file_name = target.name
item.file_extension = target.suffix.lower() or None
item.mime_type = (
mimetypes.guess_type(target.name)[0] or "application/octet-stream"
)
item.size_bytes = stat_result.st_size
item.content_hash = content_hash
item.visibility = payload.visibility
item.object_status = "available"
await session.flush()
await session.refresh(item)
return {"data": storage_payload(item), "meta": {"reused": reused}}
@app.post( @app.post(
"/internal/v1/objects/{storage_object_id}/download-url") "/internal/v1/objects/{storage_object_id}/download-url")
async def create_download_url( async def create_download_url(
+2
View File
@@ -4,10 +4,12 @@ version = "0.2.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"SQLAlchemy==2.0.51", "SQLAlchemy==2.0.51",
"greenlet>=3.0.0",
"apscheduler>=3.11.3", "apscheduler>=3.11.3",
"asyncmy==0.2.11", "asyncmy==0.2.11",
"boto3>=1.34,<2", "boto3>=1.34,<2",
"fastapi==0.116.1", "fastapi==0.116.1",
"pydantic-settings>=2.14.2",
] ]
[build-system] [build-system]
+1 -1
View File
@@ -49,7 +49,7 @@ class StorageObjects(Base):
comment="working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result", comment="working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result",
) )
storage_backend: Mapped[str] = mapped_column( storage_backend: Mapped[str] = mapped_column(
String(16), nullable=False, comment="workspace_fs/rustfs" String(16), nullable=False, comment="rustfs"
) )
storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False) storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False) file_name: Mapped[str] = mapped_column(String(255), nullable=False)
+3 -2
View File
@@ -1,15 +1,16 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Callable from typing import Any, Callable
from fastapi import FastAPI, Response, status from fastapi import FastAPI, Response, status
from common.config import settings
def _target_list() -> list[str]: def _target_list() -> list[str]:
value = os.getenv("READINESS_TARGETS", "") value = settings.readiness_targets
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in value.split(",") if item.strip()]
+2 -5
View File
@@ -76,15 +76,12 @@ services:
dockerfile: schedule/Dockerfile dockerfile: schedule/Dockerfile
restart: unless-stopped restart: unless-stopped
# No host port: architecture §2.2 — only Nginx is externally reachable. # No host port: architecture §2.2 — only Nginx is externally reachable.
# No local-FS volume: schedule executes nodes via tempfile.TemporaryDirectory
# under Python's default temp dir (cleaned per-run); artifacts live in RustFS.
environment: environment:
DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4 DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4
# schedule reads RUSTFS_ENDPOINT via schedule/service.py:build_object_store()
# (used by SchedulerService directly via boto3 — not via the storage client).
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:-http://rustfs:9000} RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:-http://rustfs:9000}
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform} RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
WORKSPACE_ROOT: /workspace/workspaces
volumes:
- ./deploy/data/workspaces:/workspace/workspaces
depends_on: depends_on:
- backend - backend
+4 -3
View File
@@ -7,15 +7,16 @@ the directory usable; downstream consumers (e.g. process.py) import it.
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
WORKSPACES_ROOT = Path(os.getenv("WORKSPACES_ROOT", "/app/workspaces")) from common.config import settings
REMOTE_BUCKET = os.getenv("REMOTE_BUCKET", "rustfs:workspaces")
WORKSPACES_ROOT = Path(settings.workspaces_root)
REMOTE_BUCKET = settings.remote_bucket
RCLONE_PROCESS: subprocess.Popen | None = None RCLONE_PROCESS: subprocess.Popen | None = None
+2 -2
View File
@@ -9,7 +9,6 @@ a thin wrapper over ``start_workspace``.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
import secrets import secrets
import subprocess import subprocess
import time import time
@@ -18,10 +17,11 @@ from typing import TypedDict
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger from loguru import logger
from common.config import settings
from common.utils import get_free_port, start_process from common.utils import get_free_port, start_process
from runtime.mount import WORKSPACES_ROOT from runtime.mount import WORKSPACES_ROOT
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost") PUBLIC_BASE_URL = settings.public_base_url
class JupyterProcessRecord(TypedDict): class JupyterProcessRecord(TypedDict):
+6 -12
View File
@@ -36,7 +36,6 @@ async def _execute_notebook(
*, *,
artifact_name: str, artifact_name: str,
arguments: list[str], arguments: list[str],
workspace_root: Path,
timeout_seconds: int, timeout_seconds: int,
) -> ExecutionResult: ) -> ExecutionResult:
output = artifact.with_name(f"executed-{artifact_name}") output = artifact.with_name(f"executed-{artifact_name}")
@@ -48,13 +47,11 @@ async def _execute_notebook(
str(artifact), str(artifact),
"--output", "--output",
str(output), str(output),
"--workspace",
str(workspace_root),
"--timeout", "--timeout",
str(max(1, timeout_seconds)), str(max(1, timeout_seconds)),
"--arguments-json", "--arguments-json",
json.dumps(arguments, ensure_ascii=False), json.dumps(arguments, ensure_ascii=False),
cwd=str(workspace_root), cwd=str(artifact.parent),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
) )
@@ -109,14 +106,13 @@ async def _execute_python(
artifact: Path, artifact: Path,
*, *,
arguments: list[str], arguments: list[str],
workspace_root: Path,
timeout_seconds: int, timeout_seconds: int,
) -> ExecutionResult: ) -> ExecutionResult:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
sys.executable, sys.executable,
str(artifact), str(artifact),
*arguments, *arguments,
cwd=str(workspace_root), cwd=str(artifact.parent),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
) )
@@ -172,16 +168,16 @@ async def execute_artifact(
script_type: str, script_type: str,
artifact_path: str, artifact_path: str,
arguments: list[str], arguments: list[str],
workspace_root: Path,
timeout_seconds: int, timeout_seconds: int,
) -> ExecutionResult: ) -> ExecutionResult:
runtime_root = workspace_root / "runtime_tmp" / "schedule-runs" # Stage the artifact under Python's system temp dir (cleaned on context
runtime_root.mkdir(parents=True, exist_ok=True) # exit). No local-FS volume assumption; the bytes only live for the
# duration of the subprocess.
suffix = ".ipynb" if script_type == "notebook" else ".py" suffix = ".ipynb" if script_type == "notebook" else ".py"
raw_name = PurePosixPath(artifact_path.replace("\\", "/")).name raw_name = PurePosixPath(artifact_path.replace("\\", "/")).name
artifact_name = raw_name if raw_name.endswith(suffix) else f"artifact{suffix}" artifact_name = raw_name if raw_name.endswith(suffix) else f"artifact{suffix}"
prefix = f"{run_id[-6:]}-{node_run_id[-6:]}-" prefix = f"{run_id[-6:]}-{node_run_id[-6:]}-"
with tempfile.TemporaryDirectory(prefix=prefix, dir=runtime_root) as directory: with tempfile.TemporaryDirectory(prefix=prefix) as directory:
artifact = Path(directory) / artifact_name artifact = Path(directory) / artifact_name
artifact.write_bytes(source) artifact.write_bytes(source)
if script_type == "notebook": if script_type == "notebook":
@@ -189,14 +185,12 @@ async def execute_artifact(
artifact, artifact,
artifact_name=artifact_name, artifact_name=artifact_name,
arguments=arguments, arguments=arguments,
workspace_root=workspace_root,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
) )
if script_type == "python": if script_type == "python":
return await _execute_python( return await _execute_python(
artifact, artifact,
arguments=arguments, arguments=arguments,
workspace_root=workspace_root,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
) )
raise ValueError(f"unsupported script_type: {script_type}") raise ValueError(f"unsupported script_type: {script_type}")
+4 -8
View File
@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
from common.config import settings
from common.db import create_database_engine, create_session_factory from common.db import create_database_engine, create_session_factory
from common.service_app import create_service_app from common.service_app import create_service_app
from schedule.service import ( from schedule.service import (
@@ -17,7 +16,7 @@ from schedule.storage_client import SchedulerStorageClient
@asynccontextmanager @asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]: async def lifespan(app: Any) -> AsyncIterator[None]:
engine = create_database_engine(os.environ["DATABASE_URL"]) engine = create_database_engine(settings.database_url)
session_factory = create_session_factory(engine) session_factory = create_session_factory(engine)
backend_http_client = build_storage_http_client() backend_http_client = build_storage_http_client()
service = SchedulerService( service = SchedulerService(
@@ -25,10 +24,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
backend_http_client=backend_http_client, backend_http_client=backend_http_client,
object_store=build_object_store(), object_store=build_object_store(),
storage_client=SchedulerStorageClient(backend_http_client), storage_client=SchedulerStorageClient(backend_http_client),
workspace_root=Path( database_url=settings.database_url,
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
),
database_url=os.environ["DATABASE_URL"],
) )
app.state.scheduler_service = service app.state.scheduler_service = service
await service.start() await service.start()
@@ -41,6 +37,6 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
app = create_service_app( app = create_service_app(
os.getenv("SERVICE_NAME", "scheduler-worker"), settings.service_name,
lifespan=lifespan, lifespan=lifespan,
) )
+4 -3
View File
@@ -36,14 +36,12 @@ def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True) parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True) parser.add_argument("--output", required=True)
parser.add_argument("--workspace", required=True)
parser.add_argument("--timeout", required=True, type=int) parser.add_argument("--timeout", required=True, type=int)
parser.add_argument("--arguments-json", default="[]") parser.add_argument("--arguments-json", default="[]")
args = parser.parse_args() args = parser.parse_args()
source = Path(args.input) source = Path(args.input)
output = Path(args.output) output = Path(args.output)
workspace = Path(args.workspace)
arguments = json.loads(args.arguments_json) arguments = json.loads(args.arguments_json)
if not isinstance(arguments, list) or not all( if not isinstance(arguments, list) or not all(
isinstance(item, str) for item in arguments isinstance(item, str) for item in arguments
@@ -68,7 +66,10 @@ def main() -> None:
kernel_name="python3", kernel_name="python3",
allow_errors=False, allow_errors=False,
) )
client.execute(cwd=str(workspace)) # No explicit cwd — the kernel inherits the parent's cwd, which the
# scheduler sets to the staged artifact directory. Keeping it here
# avoids any "cwd must exist" requirement on the host.
client.execute()
except Exception as exc: except Exception as exc:
traceback.print_exc() traceback.print_exc()
exit_code = 124 if "timeout" in type(exc).__name__.lower() else 1 exit_code = 124 if "timeout" in type(exc).__name__.lower() else 1
+7 -14
View File
@@ -18,13 +18,13 @@ Backend that ``CronScheduler`` calls at every cron tick.
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from common.config import settings
from common.ids import new_ulid from common.ids import new_ulid
from schedule.orchestrator import DispatchOrchestrator from schedule.orchestrator import DispatchOrchestrator
@@ -52,14 +52,12 @@ class SchedulerService:
backend_http_client: httpx.AsyncClient, backend_http_client: httpx.AsyncClient,
object_store: Any, object_store: Any,
storage_client: Any, storage_client: Any,
workspace_root: Any,
database_url: str, database_url: str,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.backend_http_client = backend_http_client self.backend_http_client = backend_http_client
self.object_store = object_store self.object_store = object_store
self.storage_client = storage_client self.storage_client = storage_client
self.workspace_root = workspace_root
self.database_url = database_url self.database_url = database_url
# Wire worker BEFORE orchestrator: orchestrator's dispatch table # Wire worker BEFORE orchestrator: orchestrator's dispatch table
@@ -69,7 +67,6 @@ class SchedulerService:
session_factory=session_factory, session_factory=session_factory,
object_store=object_store, object_store=object_store,
storage_client=storage_client, storage_client=storage_client,
workspace_root=workspace_root,
) )
self.orchestrator = DispatchOrchestrator( self.orchestrator = DispatchOrchestrator(
session_factory=session_factory, session_factory=session_factory,
@@ -162,20 +159,16 @@ class SchedulerService:
def build_object_store() -> Any: def build_object_store() -> Any:
"""Construct a boto3 S3 client pointed at RustFS. """Construct a boto3 S3 client pointed at RustFS.
Reads ``RUSTFS_ENDPOINT`` (full URL), ``RUSTFS_ACCESS_KEY``, and Reads ``rustfs_endpoint`` / ``rustfs_access_key`` / ``rustfs_secret_key``
``RUSTFS_SECRET_KEY``. Falls back to ``http://rustfs:9000`` for the from :data:`common.config.settings`.
endpoint — that's the default docker-compose service name.
""" """
import boto3 import boto3
return boto3.client( return boto3.client(
"s3", "s3",
endpoint_url=os.getenv( endpoint_url=settings.rustfs_endpoint,
"RUSTFS_ENDPOINT", aws_access_key_id=settings.rustfs_access_key,
"http://rustfs:9000", aws_secret_access_key=settings.rustfs_secret_key,
),
aws_access_key_id=os.environ["RUSTFS_ACCESS_KEY"],
aws_secret_access_key=os.environ["RUSTFS_SECRET_KEY"],
region_name="us-east-1", region_name="us-east-1",
) )
@@ -183,7 +176,7 @@ def build_object_store() -> Any:
def build_storage_http_client() -> httpx.AsyncClient: def build_storage_http_client() -> httpx.AsyncClient:
"""Construct the httpx client that talks to Backend's HTTP API.""" """Construct the httpx client that talks to Backend's HTTP API."""
return httpx.AsyncClient( return httpx.AsyncClient(
base_url=os.getenv("BACKEND_API_URL", "http://backend:8000"), base_url=settings.backend_api_url,
timeout=httpx.Timeout(60.0), timeout=httpx.Timeout(60.0),
) )
-7
View File
@@ -50,12 +50,10 @@ class NodeExecutor:
session_factory, session_factory,
object_store: Any, object_store: Any,
storage_client: Any, storage_client: Any,
workspace_root: Path,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.object_store = object_store self.object_store = object_store
self.storage_client = storage_client self.storage_client = storage_client
self.workspace_root = workspace_root
async def handle_node_execute( async def handle_node_execute(
self, self,
@@ -76,10 +74,6 @@ class NodeExecutor:
object_key=context["object_key"], object_key=context["object_key"],
content_hash=context["content_hash"], content_hash=context["content_hash"],
) )
workspace_root = (
self.workspace_root / context["workspace_code"]
).resolve()
workspace_root.mkdir(parents=True, exist_ok=True)
result = await execute_artifact( result = await execute_artifact(
content, content,
run_id=payload["run_id"], run_id=payload["run_id"],
@@ -87,7 +81,6 @@ class NodeExecutor:
script_type=payload["script_type"], script_type=payload["script_type"],
artifact_path=payload["artifact_path"], artifact_path=payload["artifact_path"],
arguments=[str(item) for item in payload.get("arguments", [])], arguments=[str(item) for item in payload.get("arguments", [])],
workspace_root=workspace_root,
timeout_seconds=int(payload["timeout_seconds"]), timeout_seconds=int(payload["timeout_seconds"]),
) )
except Exception as exc: except Exception as exc: