feat: schedule node add python version
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from common.db.models.schedules import FailurePolicy, TriggerType
|
||||
from common.db.models.schedules import FailurePolicy, PythonVersion, TriggerType
|
||||
from common.schemas import StrictModel
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ class CreateScheduleNodeRequest(StrictModel):
|
||||
position_y: float = Field(default=0, ge=-100_000, le=100_000)
|
||||
arguments_json: dict[str, Any] = Field(default_factory=dict, max_length=100)
|
||||
env_refs_json: dict[str, str] = Field(default_factory=dict, max_length=100)
|
||||
python_version: PythonVersion = Field(default="3.12")
|
||||
|
||||
@field_validator("node_key", "node_name")
|
||||
@classmethod
|
||||
@@ -146,6 +147,7 @@ class UpdateScheduleNodeRequest(StrictModel):
|
||||
default=None,
|
||||
max_length=100,
|
||||
)
|
||||
python_version: PythonVersion | None = Field(default=None)
|
||||
|
||||
@field_validator("node_name")
|
||||
@classmethod
|
||||
|
||||
@@ -151,6 +151,7 @@ def node_payload(
|
||||
"node_key": item.node_key,
|
||||
"node_name": item.node_name,
|
||||
"versions_id": item.versions_id,
|
||||
"python_version": item.python_version,
|
||||
"timeout_seconds": item.timeout_seconds,
|
||||
"retry_count": item.retry_count,
|
||||
"retry_interval_sec": item.retry_interval_sec,
|
||||
@@ -787,6 +788,7 @@ async def create_schedule_node(
|
||||
node_key=payload.node_key,
|
||||
node_name=payload.node_name,
|
||||
versions_id=payload.versions_id,
|
||||
python_version=payload.python_version,
|
||||
timeout_seconds=payload.timeout_seconds,
|
||||
retry_count=payload.retry_count,
|
||||
retry_interval_sec=payload.retry_interval_sec,
|
||||
@@ -837,6 +839,7 @@ async def update_schedule_node(
|
||||
mutable_fields = {
|
||||
"node_name",
|
||||
"versions_id",
|
||||
"python_version",
|
||||
"timeout_seconds",
|
||||
"retry_count",
|
||||
"retry_interval_sec",
|
||||
|
||||
@@ -11,6 +11,7 @@ from common.db.base import Base
|
||||
|
||||
TriggerType = Literal["manual", "cron", "api"]
|
||||
FailurePolicy = Literal["stop", "continue"]
|
||||
PythonVersion = Literal["3.8", "3.10", "3.12"]
|
||||
|
||||
|
||||
class Schedules(Base):
|
||||
@@ -140,6 +141,10 @@ class ScheduleNodes(Base):
|
||||
)
|
||||
node_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
python_version: Mapped[str] = mapped_column(
|
||||
String(8), nullable=False, server_default=text("'3.12'"),
|
||||
comment="节点执行 Python 版本(3.8/3.10/3.12)",
|
||||
)
|
||||
timeout_seconds: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("600")
|
||||
)
|
||||
|
||||
@@ -179,6 +179,7 @@ def upgrade() -> None:
|
||||
sa.Column('node_key', sa.String(length=64), nullable=False, comment='画布内稳定标识'),
|
||||
sa.Column('node_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('versions_id', mysql.CHAR(length=26), nullable=False),
|
||||
sa.Column('python_version', mysql.String(length=8), nullable=False, server_default=sa.text("'3.12'"), comment='节点执行 Python 版本(3.8/3.10/3.12)'),
|
||||
sa.Column('timeout_seconds', mysql.INTEGER(), server_default=sa.text('600'), nullable=False),
|
||||
sa.Column('retry_count', mysql.INTEGER(), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('retry_interval_sec', mysql.INTEGER(), server_default=sa.text('5'), nullable=False),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add schedule_nodes.python_version
|
||||
|
||||
Revision ID: g7h8i9j0k1l2
|
||||
Revises: f6a7b8c9d0e1
|
||||
Create Date: 2026-08-11
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "g7h8i9j0k1l2"
|
||||
down_revision: str | Sequence[str] | None = "f6a7b8c9d0e1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"schedule_nodes",
|
||||
sa.Column(
|
||||
"python_version",
|
||||
sa.String(length=8),
|
||||
nullable=False,
|
||||
server_default=sa.text("'3.12'"),
|
||||
comment="节点执行 Python 版本(3.8/3.10/3.12)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("schedule_nodes", "python_version")
|
||||
+20
-5
@@ -7,13 +7,28 @@ COPY extras ./extras
|
||||
RUN UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv sync --frozen --no-dev --no-editable --package schedule
|
||||
|
||||
RUN uv venv --python 3.12 /opt/venv/python3.12 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.12/bin/python -r extras/requirements-py312
|
||||
|
||||
RUN uv venv --python 3.8 /opt/venv/python3.8 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.8/bin/python -r extras/requirements-py38
|
||||
UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
uv pip install --python /opt/venv/python3.12/bin/python -r extras/requirements-py312 && \
|
||||
/opt/venv/python3.12/bin/python -m ipykernel install \
|
||||
--prefix=/usr/local \
|
||||
--name=python312 \
|
||||
--display-name="Python 3.12"
|
||||
|
||||
RUN uv venv --python 3.10 /opt/venv/python3.10 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.10/bin/python -r extras/requirements-py310
|
||||
UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
uv pip install --python /opt/venv/python3.10/bin/python -r extras/requirements-py310 && \
|
||||
/opt/venv/python3.10/bin/python -m ipykernel install \
|
||||
--prefix=/usr/local \
|
||||
--name=python310 \
|
||||
--display-name="Python 3.10"
|
||||
|
||||
RUN uv venv --python 3.8 /opt/venv/python3.8 && \
|
||||
UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
uv pip install --python /opt/venv/python3.8/bin/python -r extras/requirements-py38 && \
|
||||
/opt/venv/python3.8/bin/python -m ipykernel install \
|
||||
--prefix=/usr/local \
|
||||
--name=python38 \
|
||||
--display-name="Python 3.8"
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--package", "schedule", "gunicorn", "--config", "schedule/gunicorn.conf.py", "schedule.main:app"]
|
||||
|
||||
@@ -39,12 +39,14 @@ async def _execute_notebook(
|
||||
artifact_name: str,
|
||||
arguments: list[str],
|
||||
timeout_seconds: int,
|
||||
python_version: str = "3.12",
|
||||
) -> ExecutionResult:
|
||||
output = artifact.with_name(f"executed-{artifact_name}")
|
||||
logger.debug(
|
||||
"notebook exec start: artifact={} timeout={}s args={}",
|
||||
"notebook exec start: artifact={} timeout={}s version={} args={}",
|
||||
artifact_name,
|
||||
timeout_seconds,
|
||||
python_version,
|
||||
len(arguments),
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
@@ -59,6 +61,8 @@ async def _execute_notebook(
|
||||
str(max(1, timeout_seconds)),
|
||||
"--arguments-json",
|
||||
json.dumps(arguments, ensure_ascii=False),
|
||||
"--python-version",
|
||||
python_version,
|
||||
cwd=str(artifact.parent),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
@@ -76,9 +80,10 @@ async def _execute_notebook(
|
||||
error_code = "NODE_TIMEOUT"
|
||||
error_message = f"notebook exceeded timeout of {timeout_seconds} seconds"
|
||||
logger.warning(
|
||||
"notebook exec timed out: artifact={} timeout={}s",
|
||||
"notebook exec timed out: artifact={} timeout={}s version={}",
|
||||
artifact_name,
|
||||
timeout_seconds,
|
||||
python_version,
|
||||
)
|
||||
else:
|
||||
exit_code = process.returncode
|
||||
@@ -114,9 +119,10 @@ async def _execute_notebook(
|
||||
error_message=error_message,
|
||||
)
|
||||
logger.info(
|
||||
"notebook exec done: artifact={} status={} exit_code={}",
|
||||
"notebook exec done: artifact={} status={} version={} exit_code={}",
|
||||
artifact_name,
|
||||
result.status,
|
||||
python_version,
|
||||
result.exit_code,
|
||||
)
|
||||
return result
|
||||
@@ -127,11 +133,15 @@ async def _execute_python(
|
||||
*,
|
||||
arguments: list[str],
|
||||
timeout_seconds: int,
|
||||
python_version: str = "3.12",
|
||||
) -> ExecutionResult:
|
||||
# NOTE: The worker process runs a single interpreter, so .py scripts
|
||||
# continue to use sys.executable. Switching interpreters is future work.
|
||||
logger.debug(
|
||||
"python exec start: artifact={} timeout={}s args={}",
|
||||
"python exec start: artifact={} timeout={}s version={} args={}",
|
||||
artifact.name,
|
||||
timeout_seconds,
|
||||
python_version,
|
||||
len(arguments),
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
@@ -156,9 +166,10 @@ async def _execute_python(
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
logger.warning(
|
||||
"python exec timed out: artifact={} timeout={}s",
|
||||
"python exec timed out: artifact={} timeout={}s version={}",
|
||||
artifact.name,
|
||||
timeout_seconds,
|
||||
python_version,
|
||||
)
|
||||
return ExecutionResult(
|
||||
status="timed_out",
|
||||
@@ -190,9 +201,10 @@ async def _execute_python(
|
||||
error_message=message,
|
||||
)
|
||||
logger.info(
|
||||
"python exec done: artifact={} status={} exit_code={}",
|
||||
"python exec done: artifact={} status={} version={} exit_code={}",
|
||||
artifact.name,
|
||||
result.status,
|
||||
python_version,
|
||||
result.exit_code,
|
||||
)
|
||||
return result
|
||||
@@ -207,6 +219,7 @@ async def execute_artifact(
|
||||
artifact_path: str,
|
||||
arguments: list[str],
|
||||
timeout_seconds: int,
|
||||
python_version: str = "3.12",
|
||||
) -> ExecutionResult:
|
||||
logger.debug(
|
||||
"execute_artifact: run={} node={} script_type={}",
|
||||
@@ -230,12 +243,14 @@ async def execute_artifact(
|
||||
artifact_name=artifact_name,
|
||||
arguments=arguments,
|
||||
timeout_seconds=timeout_seconds,
|
||||
python_version=python_version,
|
||||
)
|
||||
if script_type == "python":
|
||||
return await _execute_python(
|
||||
artifact,
|
||||
arguments=arguments,
|
||||
timeout_seconds=timeout_seconds,
|
||||
python_version=python_version,
|
||||
)
|
||||
logger.error("unsupported script_type: {}", script_type)
|
||||
raise ValueError(f"unsupported script_type: {script_type}")
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
import nbformat
|
||||
from loguru import logger
|
||||
from nbclient import NotebookClient
|
||||
|
||||
|
||||
@@ -27,8 +24,7 @@ def emit_outputs(notebook: object) -> None:
|
||||
)
|
||||
elif output_type == "error":
|
||||
print(
|
||||
f"{output.get('ename', 'Error')}: "
|
||||
f"{output.get('evalue', '')}",
|
||||
f"{output.get('ename', 'Error')}: {output.get('evalue', '')}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
@@ -39,6 +35,11 @@ def main() -> None:
|
||||
parser.add_argument("--input", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--timeout", required=True, type=int)
|
||||
parser.add_argument(
|
||||
"--python-version",
|
||||
choices=("3.8", "3.10", "3.12"),
|
||||
default="3.12",
|
||||
)
|
||||
parser.add_argument("--arguments-json", default="[]")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -51,9 +52,10 @@ def main() -> None:
|
||||
raise ValueError("arguments-json must contain an array of strings")
|
||||
|
||||
logger.info(
|
||||
"notebook runner start: input={} timeout={}s args={}",
|
||||
"notebook runner start: input={} timeout={}s python={} args={}",
|
||||
source.name,
|
||||
args.timeout,
|
||||
args.python_version,
|
||||
len(arguments),
|
||||
)
|
||||
notebook = nbformat.read(source, as_version=4)
|
||||
@@ -68,14 +70,16 @@ def main() -> None:
|
||||
)
|
||||
exit_code = 0
|
||||
try:
|
||||
kernel_name = f"python{args.python_version.replace('.', '')}"
|
||||
client = NotebookClient(
|
||||
notebook,
|
||||
timeout=max(1, args.timeout),
|
||||
kernel_name="python3",
|
||||
kernel_name=kernel_name,
|
||||
allow_errors=False,
|
||||
)
|
||||
logger.debug(
|
||||
"notebook client created: kernel=python3 timeout={}s",
|
||||
"notebook client created: kernel={} timeout={}s",
|
||||
kernel_name,
|
||||
max(1, args.timeout),
|
||||
)
|
||||
# No explicit cwd — the kernel inherits the parent's cwd, which the
|
||||
|
||||
@@ -28,6 +28,7 @@ from common.db import session_scope
|
||||
from common.db.models import (
|
||||
ConsumerInbox,
|
||||
ScheduleNodeRuns,
|
||||
ScheduleNodes,
|
||||
ScheduleRuns,
|
||||
Schedules,
|
||||
StorageObjects,
|
||||
@@ -86,6 +87,9 @@ class NodeExecutor:
|
||||
object_key=context["object_key"],
|
||||
content_hash=context["content_hash"],
|
||||
)
|
||||
python_version = await self._node_python_version(
|
||||
payload["node_run_id"]
|
||||
)
|
||||
result = await execute_artifact(
|
||||
content,
|
||||
run_id=payload["run_id"],
|
||||
@@ -94,6 +98,7 @@ class NodeExecutor:
|
||||
artifact_path=payload["artifact_path"],
|
||||
arguments=[str(item) for item in payload.get("arguments", [])],
|
||||
timeout_seconds=int(payload["timeout_seconds"]),
|
||||
python_version=python_version,
|
||||
)
|
||||
except Exception as exc:
|
||||
trace = traceback.format_exc()
|
||||
@@ -250,6 +255,29 @@ class NodeExecutor:
|
||||
)
|
||||
return True
|
||||
|
||||
async def _node_python_version(
|
||||
self,
|
||||
node_run_id: str,
|
||||
) -> str:
|
||||
async with self.session_factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(ScheduleNodes.python_version)
|
||||
.join(
|
||||
ScheduleNodeRuns,
|
||||
ScheduleNodeRuns.node_id == ScheduleNodes.node_id,
|
||||
)
|
||||
.where(ScheduleNodeRuns.node_run_id == node_run_id)
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
logger.warning(
|
||||
"node python_version not found, defaulting to 3.12: node_run={}",
|
||||
node_run_id[-12:],
|
||||
)
|
||||
return "3.12"
|
||||
return row[0]
|
||||
|
||||
async def _execution_context(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
|
||||
Reference in New Issue
Block a user