diff --git a/docs/api_reference/source/rest-api.rst b/docs/api_reference/source/rest-api.rst index e943c56022607..5529393af1e4a 100755 --- a/docs/api_reference/source/rest-api.rst +++ b/docs/api_reference/source/rest-api.rst @@ -6966,6 +6966,28 @@ Reference to an issue associated with this trace | issue_name | ``STRING`` | The name of the issue this assessment references This field is required. | +------------+------------+--------------------------------------------------------------------------+ +.. _mlflowJobProgress: + +JobProgress +----------- + + + +Structured best-effort progress payload for a running job. + + ++------------+------------+-------------+ +| Field Name | Type | Description | ++============+============+=============+ +| phase | ``STRING`` | | ++------------+------------+-------------+ +| completed | ``INT64`` | | ++------------+------------+-------------+ +| total | ``INT64`` | | ++------------+------------+-------------+ +| unit | ``STRING`` | | ++------------+------------+-------------+ + .. _mlflowJobState: JobState @@ -6977,15 +6999,21 @@ Generic job state message combining status with metadata. Provides a unified way to represent job state across different job types. -+---------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ -| Field Name | Type | Description | -+===============+================================================+==============================================================================================+ -| status | :ref:`mlflowjobstatus` | Current status of the job. | -+---------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ -| error_message | ``STRING`` | Error message if the job failed. Only set when status is JOB_STATUS_FAILED. | -+---------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ -| metadata | An array of :ref:`mlflowjobstatemetadataentry` | Additional metadata as key-value pairs. Can be used to store job-specific state information. | -+---------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ +| Field Name | Type | Description | ++=====================+================================================+==============================================================================================+ +| status | :ref:`mlflowjobstatus` | Current status of the job. | ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ +| error_message | ``STRING`` | Error message if the job failed. Only set when status is JOB_STATUS_FAILED. | ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ +| metadata | An array of :ref:`mlflowjobstatemetadataentry` | Additional metadata as key-value pairs. Can be used to store job-specific state information. | ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ +| status_message | ``STRING`` | Latest best-effort in-flight status message. | ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ +| progress_payload | :ref:`mlflowjobprogress` | Latest best-effort structured progress payload. | ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ +| progress_updated_at | ``INT64`` | Timestamp of the latest progress update in milliseconds since epoch. | ++---------------------+------------------------------------------------+----------------------------------------------------------------------------------------------+ .. _mlflowLinkPromptsToTrace: @@ -9600,21 +9628,23 @@ JobStatus Generic status enum for MLflow jobs. Can be used across different job types (optimization, scorer, etc.). -+------------------------+----------------------------------+ -| Name | Description | -+========================+==================================+ -| JOB_STATUS_UNSPECIFIED | | -+------------------------+----------------------------------+ -| JOB_STATUS_PENDING | Job is queued, waiting to start. | -+------------------------+----------------------------------+ -| JOB_STATUS_IN_PROGRESS | Job is currently running. | -+------------------------+----------------------------------+ -| JOB_STATUS_COMPLETED | Job completed successfully. | -+------------------------+----------------------------------+ -| JOB_STATUS_FAILED | Job failed with an error. | -+------------------------+----------------------------------+ -| JOB_STATUS_CANCELED | Job was canceled by user. | -+------------------------+----------------------------------+ ++---------------------------+----------------------------------------------------------------------------+ +| Name | Description | ++===========================+============================================================================+ +| JOB_STATUS_UNSPECIFIED | | ++---------------------------+----------------------------------------------------------------------------+ +| JOB_STATUS_PENDING | Job is queued, waiting to start. | ++---------------------------+----------------------------------------------------------------------------+ +| JOB_STATUS_IN_PROGRESS | Job is currently running. | ++---------------------------+----------------------------------------------------------------------------+ +| JOB_STATUS_COMPLETED | Job completed successfully. | ++---------------------------+----------------------------------------------------------------------------+ +| JOB_STATUS_FAILED | Job failed with an error. | ++---------------------------+----------------------------------------------------------------------------+ +| JOB_STATUS_CANCELED | Job was canceled by user. | ++---------------------------+----------------------------------------------------------------------------+ +| JOB_STATUS_NEEDS_RECOVERY | Job backend work may still exist, but the current watcher is unresponsive. | ++---------------------------+----------------------------------------------------------------------------+ .. _mlflowLoggedModelStatus: diff --git a/mlflow/entities/_job.py b/mlflow/entities/_job.py index 9019b69aac6f7..3831df30873f0 100644 --- a/mlflow/entities/_job.py +++ b/mlflow/entities/_job.py @@ -1,11 +1,131 @@ import json -from typing import Any +from dataclasses import dataclass +from typing import Any, Literal from mlflow.entities._job_status import JobStatus from mlflow.entities._mlflow_object import _MlflowObject +from mlflow.protos.jobs_pb2 import JobProgress as ProtoJobProgress from mlflow.utils.workspace_utils import resolve_entity_workspace_name +@dataclass +class JobProgress: + """ + Structured best-effort progress payload for an in-flight job. + + This keeps progress machine-readable while still allowing it to be + stored as JSON in the backing row and sent through the job APIs. + + Attributes: + phase: Short label for the current stage of work, such as + ``"scoring traces"`` or ``"uploading artifacts"``. + completed: Number of work units finished so far. + total: Total number of work units, when known. + unit: Human-readable name for the work unit, such as ``"trace"`` + or ``"file"``. + + Example: + A trace-scoring job that has processed 42 out of 100 traces could + report ``JobProgress(phase="scoring traces", completed=42, + total=100, unit="trace")``. + """ + + phase: str | None = None + completed: int | None = None + total: int | None = None + unit: str | None = None + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "JobProgress": + return cls( + phase=payload.get("phase"), + completed=payload.get("completed"), + total=payload.get("total"), + unit=payload.get("unit"), + ) + + def to_dict(self) -> dict[str, Any]: + payload = {} + if self.phase is not None: + payload["phase"] = self.phase + if self.completed is not None: + payload["completed"] = self.completed + if self.total is not None: + payload["total"] = self.total + if self.unit is not None: + payload["unit"] = self.unit + return payload + + @classmethod + def from_proto(cls, proto: ProtoJobProgress) -> "JobProgress": + return cls( + phase=proto.phase or None, + completed=proto.completed if proto.HasField("completed") else None, + total=proto.total if proto.HasField("total") else None, + unit=proto.unit or None, + ) + + def to_proto(self) -> ProtoJobProgress: + progress = ProtoJobProgress() + if self.phase is not None: + progress.phase = self.phase + if self.completed is not None: + progress.completed = self.completed + if self.total is not None: + progress.total = self.total + if self.unit is not None: + progress.unit = self.unit + return progress + + +ScopedPermissionResourceType = Literal["experiment", "gateway_endpoint", "prompt"] +ScopedPermissionName = Literal["READ", "USE", "EDIT"] + + +@dataclass(frozen=True) +class JobScopedPermission: + """ + A single resource permission granted to a job token. + + Attributes: + resource_type: Type of protected MLflow resource. + resource_identifier: Stable identifier for the protected resource. + workspace: Workspace that owns the resource, when workspace scoping is + relevant to authorization. + permission: Permission granted for the resource. + + Example: + A job allowed to post assessments for experiment ``123`` in workspace + ``team-a`` could include ``JobScopedPermission( + resource_type="experiment", resource_identifier="123", + workspace="team-a", permission="EDIT")``. + """ + + resource_type: ScopedPermissionResourceType + resource_identifier: str + workspace: str | None = None + permission: ScopedPermissionName = "READ" + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "JobScopedPermission": + return cls( + resource_type=payload["resource_type"], + resource_identifier=payload["resource_identifier"], + workspace=payload.get("workspace"), + permission=payload["permission"], + ) + + def to_dict(self) -> dict[str, Any]: + payload = { + "resource_type": self.resource_type, + "resource_identifier": self.resource_identifier, + "permission": self.permission, + } + if self.workspace is not None: + payload["workspace"] = self.workspace + return payload + + class Job(_MlflowObject): """ MLflow entity representing a Job. @@ -24,6 +144,14 @@ def __init__( last_update_time: int, workspace: str | None = None, status_details: dict[str, Any] | None = None, + error_message: str | None = None, + executor_backend: str | None = None, + lease_expires_at: int | None = None, + status_message: str | None = None, + progress_payload: JobProgress | dict[str, Any] | None = None, + progress_updated_at: int | None = None, + token_hash: str | None = None, + scoped_permissions: list[JobScopedPermission] | list[dict[str, Any]] | None = None, ): super().__init__() self._job_id = job_id @@ -37,6 +165,23 @@ def __init__( self._last_update_time = last_update_time self._workspace = resolve_entity_workspace_name(workspace) self._status_details = status_details + self._error_message = error_message + self._executor_backend = executor_backend + self._lease_expires_at = lease_expires_at + self._status_message = status_message + self._progress_payload = ( + JobProgress.from_dict(progress_payload) + if isinstance(progress_payload, dict) + else progress_payload + ) + self._progress_updated_at = progress_updated_at + self._token_hash = token_hash + self._scoped_permissions = ( + [JobScopedPermission.from_dict(permission) for permission in scoped_permissions] + if scoped_permissions is not None + and all(isinstance(permission, dict) for permission in scoped_permissions) + else scoped_permissions + ) @property def job_id(self) -> str: @@ -93,6 +238,8 @@ def parsed_result(self) -> Any: If job status is FAILED, the parsed result is the error string. Otherwise, the parsed result is None. """ + if self.result is None: + return None if self.status == JobStatus.SUCCEEDED: return json.loads(self.result) return self.result @@ -117,5 +264,93 @@ def status_details(self) -> dict[str, Any] | None: """Job status details containing other runtime information.""" return self._status_details + @property + def error_message(self) -> str | None: + """ + Human-readable terminal error for operators and UI surfaces. + + This is set for failed or timed-out jobs when a terminal error message + is available. It may be absent for successful, canceled, pending, or + in-flight jobs. + """ + if self._error_message is not None: + return self._error_message + if self.status in {JobStatus.FAILED, JobStatus.TIMEOUT} and isinstance(self.result, str): + return self.result + return None + + @property + def executor_backend(self) -> str | None: + """ + Persisted executor backend selected for the job. + + This is framework coordination state used to keep retries, cancellation, + and recovery pinned to the same backend choice for the lifetime of the + job row. + """ + return self._executor_backend + + @property + def lease_expires_at(self) -> int | None: + """ + Expiration timestamp for the job's short-lived execution lease. + + This is distinct from terminal outcome fields and exists so recovery + logic can tell whether a `RUNNING` job still appears healthy. + """ + return self._lease_expires_at + + @property + def status_message(self) -> str | None: + """ + Latest best-effort in-flight status message. + + This is the lightweight plain-text progress channel for operators and + simple UI surfaces. It exists so jobs can report useful progress text + even when they do not emit a structured `progress_payload`. + """ + return self._status_message + + @property + def progress_payload(self) -> JobProgress | None: + """ + Latest best-effort structured progress payload. + + This is intentionally distinct from `status_message`: the string message + is the plain-text progress channel, while `progress_payload` is the + machine-readable form that can carry fields such as phase, completed, + total, and unit for richer shared progress UIs. + """ + return self._progress_payload + + @property + def progress_updated_at(self) -> int | None: + """ + Timestamp of the latest structured or message-based progress update, in + milliseconds since the UNIX epoch. + """ + return self._progress_updated_at + + @property + def token_hash(self) -> str | None: + """ + Persisted hash of the remote-execution job token. + + This is internal framework auth state. The plaintext token is never + stored on the entity or in the database row. + """ + return self._token_hash + + @property + def scoped_permissions(self) -> list[JobScopedPermission] | None: + """ + Persisted permissions scoped to this job's remote-execution contract. + + Each entry describes one protected resource the job token may access, + including its resource type, stable identifier, workspace, and granted + permission. + """ + return self._scoped_permissions + def __repr__(self) -> str: return f"" diff --git a/mlflow/entities/_job_status.py b/mlflow/entities/_job_status.py index 9f07d97860297..9d3407cb9b4b6 100644 --- a/mlflow/entities/_job_status.py +++ b/mlflow/entities/_job_status.py @@ -9,6 +9,7 @@ class JobStatus(str, Enum): PENDING = "PENDING" RUNNING = "RUNNING" + NEEDS_RECOVERY = "NEEDS_RECOVERY" SUCCEEDED = "SUCCEEDED" FAILED = "FAILED" TIMEOUT = "TIMEOUT" @@ -17,12 +18,20 @@ class JobStatus(str, Enum): @classmethod def from_int(cls, status_int: int) -> "JobStatus": """Convert integer status to JobStatus enum.""" - try: - return next(e for i, e in enumerate(JobStatus) if i == status_int) - except StopIteration: - raise MlflowException.invalid_parameter_value( - f"The value {status_int} can't be converted to JobStatus enum value." - ) + mapping = { + 0: JobStatus.PENDING, + 1: JobStatus.RUNNING, + 2: JobStatus.SUCCEEDED, + 3: JobStatus.FAILED, + 4: JobStatus.TIMEOUT, + 5: JobStatus.CANCELED, + 6: JobStatus.NEEDS_RECOVERY, + } + if status := mapping.get(status_int): + return status + raise MlflowException.invalid_parameter_value( + f"The value {status_int} can't be converted to JobStatus enum value." + ) @classmethod def from_str(cls, status_str: str) -> "JobStatus": @@ -36,13 +45,22 @@ def from_str(cls, status_str: str) -> "JobStatus": def to_int(self) -> int: """Convert JobStatus enum to integer.""" - return next(i for i, e in enumerate(JobStatus) if e == self) + return { + JobStatus.PENDING: 0, + JobStatus.RUNNING: 1, + JobStatus.SUCCEEDED: 2, + JobStatus.FAILED: 3, + JobStatus.TIMEOUT: 4, + JobStatus.CANCELED: 5, + JobStatus.NEEDS_RECOVERY: 6, + }[self] def to_proto(self) -> int: """Convert JobStatus enum to proto JobStatus enum value.""" mapping = { JobStatus.PENDING: ProtoJobStatus.JOB_STATUS_PENDING, JobStatus.RUNNING: ProtoJobStatus.JOB_STATUS_IN_PROGRESS, + JobStatus.NEEDS_RECOVERY: ProtoJobStatus.JOB_STATUS_NEEDS_RECOVERY, JobStatus.SUCCEEDED: ProtoJobStatus.JOB_STATUS_COMPLETED, JobStatus.FAILED: ProtoJobStatus.JOB_STATUS_FAILED, JobStatus.TIMEOUT: ProtoJobStatus.JOB_STATUS_FAILED, # No TIMEOUT in proto diff --git a/mlflow/java/client/src/main/java/org/mlflow/api/proto/Jobs.java b/mlflow/java/client/src/main/java/org/mlflow/api/proto/Jobs.java index 3a352ed1d206f..d191d72efb7ab 100644 --- a/mlflow/java/client/src/main/java/org/mlflow/api/proto/Jobs.java +++ b/mlflow/java/client/src/main/java/org/mlflow/api/proto/Jobs.java @@ -68,6 +68,14 @@ public enum JobStatus * JOB_STATUS_CANCELED = 5; */ JOB_STATUS_CANCELED(5), + /** + *
+     * Job backend work may still exist, but the current watcher is unresponsive.
+     * 
+ * + * JOB_STATUS_NEEDS_RECOVERY = 6; + */ + JOB_STATUS_NEEDS_RECOVERY(6), ; /** @@ -114,81 +122,1086 @@ public enum JobStatus * JOB_STATUS_CANCELED = 5; */ public static final int JOB_STATUS_CANCELED_VALUE = 5; + /** + *
+     * Job backend work may still exist, but the current watcher is unresponsive.
+     * 
+ * + * JOB_STATUS_NEEDS_RECOVERY = 6; + */ + public static final int JOB_STATUS_NEEDS_RECOVERY_VALUE = 6; + + + public final int getNumber() { + return value; + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + * @deprecated Use {@link #forNumber(int)} instead. + */ + @java.lang.Deprecated + public static JobStatus valueOf(int value) { + return forNumber(value); + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + */ + public static JobStatus forNumber(int value) { + switch (value) { + case 0: return JOB_STATUS_UNSPECIFIED; + case 1: return JOB_STATUS_PENDING; + case 2: return JOB_STATUS_IN_PROGRESS; + case 3: return JOB_STATUS_COMPLETED; + case 4: return JOB_STATUS_FAILED; + case 5: return JOB_STATUS_CANCELED; + case 6: return JOB_STATUS_NEEDS_RECOVERY; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static final com.google.protobuf.Internal.EnumLiteMap< + JobStatus> internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public JobStatus findValueByNumber(int number) { + return JobStatus.forNumber(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + return getDescriptor().getValues().get(ordinal()); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return org.mlflow.api.proto.Jobs.getDescriptor().getEnumTypes().get(0); + } + + private static final JobStatus[] VALUES = values(); + + public static JobStatus valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + return VALUES[desc.getIndex()]; + } + + private final int value; + + private JobStatus(int value) { + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:mlflow.JobStatus) + } + + public interface JobProgressOrBuilder extends + // @@protoc_insertion_point(interface_extends:mlflow.JobProgress) + com.google.protobuf.MessageOrBuilder { + + /** + * optional string phase = 1; + * @return Whether the phase field is set. + */ + boolean hasPhase(); + /** + * optional string phase = 1; + * @return The phase. + */ + java.lang.String getPhase(); + /** + * optional string phase = 1; + * @return The bytes for phase. + */ + com.google.protobuf.ByteString + getPhaseBytes(); + + /** + * optional int64 completed = 2; + * @return Whether the completed field is set. + */ + boolean hasCompleted(); + /** + * optional int64 completed = 2; + * @return The completed. + */ + long getCompleted(); + + /** + * optional int64 total = 3; + * @return Whether the total field is set. + */ + boolean hasTotal(); + /** + * optional int64 total = 3; + * @return The total. + */ + long getTotal(); + + /** + * optional string unit = 4; + * @return Whether the unit field is set. + */ + boolean hasUnit(); + /** + * optional string unit = 4; + * @return The unit. + */ + java.lang.String getUnit(); + /** + * optional string unit = 4; + * @return The bytes for unit. + */ + com.google.protobuf.ByteString + getUnitBytes(); + } + /** + *
+   * Structured best-effort progress payload for a running job.
+   * 
+ * + * Protobuf type {@code mlflow.JobProgress} + */ + public static final class JobProgress extends + com.google.protobuf.GeneratedMessageV3 implements + // @@protoc_insertion_point(message_implements:mlflow.JobProgress) + JobProgressOrBuilder { + private static final long serialVersionUID = 0L; + // Use JobProgress.newBuilder() to construct. + private JobProgress(com.google.protobuf.GeneratedMessageV3.Builder builder) { + super(builder); + } + private JobProgress() { + phase_ = ""; + unit_ = ""; + } + + @java.lang.Override + @SuppressWarnings({"unused"}) + protected java.lang.Object newInstance( + UnusedPrivateParameter unused) { + return new JobProgress(); + } + + @java.lang.Override + public final com.google.protobuf.UnknownFieldSet + getUnknownFields() { + return this.unknownFields; + } + private JobProgress( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + this(); + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } + int mutable_bitField0_ = 0; + com.google.protobuf.UnknownFieldSet.Builder unknownFields = + com.google.protobuf.UnknownFieldSet.newBuilder(); + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 10: { + com.google.protobuf.ByteString bs = input.readBytes(); + bitField0_ |= 0x00000001; + phase_ = bs; + break; + } + case 16: { + bitField0_ |= 0x00000002; + completed_ = input.readInt64(); + break; + } + case 24: { + bitField0_ |= 0x00000004; + total_ = input.readInt64(); + break; + } + case 34: { + com.google.protobuf.ByteString bs = input.readBytes(); + bitField0_ |= 0x00000008; + unit_ = bs; + break; + } + default: { + if (!parseUnknownField( + input, unknownFields, extensionRegistry, tag)) { + done = true; + } + break; + } + } + } + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(this); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException( + e).setUnfinishedMessage(this); + } finally { + this.unknownFields = unknownFields.build(); + makeExtensionsImmutable(); + } + } + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.mlflow.api.proto.Jobs.internal_static_mlflow_JobProgress_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.mlflow.api.proto.Jobs.internal_static_mlflow_JobProgress_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.mlflow.api.proto.Jobs.JobProgress.class, org.mlflow.api.proto.Jobs.JobProgress.Builder.class); + } + + private int bitField0_; + public static final int PHASE_FIELD_NUMBER = 1; + private volatile java.lang.Object phase_; + /** + * optional string phase = 1; + * @return Whether the phase field is set. + */ + @java.lang.Override + public boolean hasPhase() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + * optional string phase = 1; + * @return The phase. + */ + @java.lang.Override + public java.lang.String getPhase() { + java.lang.Object ref = phase_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + phase_ = s; + } + return s; + } + } + /** + * optional string phase = 1; + * @return The bytes for phase. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPhaseBytes() { + java.lang.Object ref = phase_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + phase_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int COMPLETED_FIELD_NUMBER = 2; + private long completed_; + /** + * optional int64 completed = 2; + * @return Whether the completed field is set. + */ + @java.lang.Override + public boolean hasCompleted() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + * optional int64 completed = 2; + * @return The completed. + */ + @java.lang.Override + public long getCompleted() { + return completed_; + } + + public static final int TOTAL_FIELD_NUMBER = 3; + private long total_; + /** + * optional int64 total = 3; + * @return Whether the total field is set. + */ + @java.lang.Override + public boolean hasTotal() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional int64 total = 3; + * @return The total. + */ + @java.lang.Override + public long getTotal() { + return total_; + } + public static final int UNIT_FIELD_NUMBER = 4; + private volatile java.lang.Object unit_; + /** + * optional string unit = 4; + * @return Whether the unit field is set. + */ + @java.lang.Override + public boolean hasUnit() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional string unit = 4; + * @return The unit. + */ + @java.lang.Override + public java.lang.String getUnit() { + java.lang.Object ref = unit_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + unit_ = s; + } + return s; + } + } + /** + * optional string unit = 4; + * @return The bytes for unit. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getUnitBytes() { + java.lang.Object ref = unit_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + unit_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + private byte memoizedIsInitialized = -1; + @java.lang.Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @java.lang.Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (((bitField0_ & 0x00000001) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 1, phase_); + } + if (((bitField0_ & 0x00000002) != 0)) { + output.writeInt64(2, completed_); + } + if (((bitField0_ & 0x00000004) != 0)) { + output.writeInt64(3, total_); + } + if (((bitField0_ & 0x00000008) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 4, unit_); + } + unknownFields.writeTo(output); + } + + @java.lang.Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (((bitField0_ & 0x00000001) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, phase_); + } + if (((bitField0_ & 0x00000002) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(2, completed_); + } + if (((bitField0_ & 0x00000004) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(3, total_); + } + if (((bitField0_ & 0x00000008) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, unit_); + } + size += unknownFields.getSerializedSize(); + memoizedSize = size; + return size; + } + + @java.lang.Override + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof org.mlflow.api.proto.Jobs.JobProgress)) { + return super.equals(obj); + } + org.mlflow.api.proto.Jobs.JobProgress other = (org.mlflow.api.proto.Jobs.JobProgress) obj; + + if (hasPhase() != other.hasPhase()) return false; + if (hasPhase()) { + if (!getPhase() + .equals(other.getPhase())) return false; + } + if (hasCompleted() != other.hasCompleted()) return false; + if (hasCompleted()) { + if (getCompleted() + != other.getCompleted()) return false; + } + if (hasTotal() != other.hasTotal()) return false; + if (hasTotal()) { + if (getTotal() + != other.getTotal()) return false; + } + if (hasUnit() != other.hasUnit()) return false; + if (hasUnit()) { + if (!getUnit() + .equals(other.getUnit())) return false; + } + if (!unknownFields.equals(other.unknownFields)) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + if (hasPhase()) { + hash = (37 * hash) + PHASE_FIELD_NUMBER; + hash = (53 * hash) + getPhase().hashCode(); + } + if (hasCompleted()) { + hash = (37 * hash) + COMPLETED_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getCompleted()); + } + if (hasTotal()) { + hash = (37 * hash) + TOTAL_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getTotal()); + } + if (hasUnit()) { + hash = (37 * hash) + UNIT_FIELD_NUMBER; + hash = (53 * hash) + getUnit().hashCode(); + } + hash = (29 * hash) + unknownFields.hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static org.mlflow.api.proto.Jobs.JobProgress parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @java.lang.Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(org.mlflow.api.proto.Jobs.JobProgress prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + *
+     * Structured best-effort progress payload for a running job.
+     * 
+ * + * Protobuf type {@code mlflow.JobProgress} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageV3.Builder implements + // @@protoc_insertion_point(builder_implements:mlflow.JobProgress) + org.mlflow.api.proto.Jobs.JobProgressOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.mlflow.api.proto.Jobs.internal_static_mlflow_JobProgress_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.mlflow.api.proto.Jobs.internal_static_mlflow_JobProgress_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.mlflow.api.proto.Jobs.JobProgress.class, org.mlflow.api.proto.Jobs.JobProgress.Builder.class); + } + + // Construct using org.mlflow.api.proto.Jobs.JobProgress.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessageV3 + .alwaysUseFieldBuilders) { + } + } + @java.lang.Override + public Builder clear() { + super.clear(); + phase_ = ""; + bitField0_ = (bitField0_ & ~0x00000001); + completed_ = 0L; + bitField0_ = (bitField0_ & ~0x00000002); + total_ = 0L; + bitField0_ = (bitField0_ & ~0x00000004); + unit_ = ""; + bitField0_ = (bitField0_ & ~0x00000008); + return this; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return org.mlflow.api.proto.Jobs.internal_static_mlflow_JobProgress_descriptor; + } + + @java.lang.Override + public org.mlflow.api.proto.Jobs.JobProgress getDefaultInstanceForType() { + return org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance(); + } + + @java.lang.Override + public org.mlflow.api.proto.Jobs.JobProgress build() { + org.mlflow.api.proto.Jobs.JobProgress result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @java.lang.Override + public org.mlflow.api.proto.Jobs.JobProgress buildPartial() { + org.mlflow.api.proto.Jobs.JobProgress result = new org.mlflow.api.proto.Jobs.JobProgress(this); + int from_bitField0_ = bitField0_; + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000001) != 0)) { + to_bitField0_ |= 0x00000001; + } + result.phase_ = phase_; + if (((from_bitField0_ & 0x00000002) != 0)) { + result.completed_ = completed_; + to_bitField0_ |= 0x00000002; + } + if (((from_bitField0_ & 0x00000004) != 0)) { + result.total_ = total_; + to_bitField0_ |= 0x00000004; + } + if (((from_bitField0_ & 0x00000008) != 0)) { + to_bitField0_ |= 0x00000008; + } + result.unit_ = unit_; + result.bitField0_ = to_bitField0_; + onBuilt(); + return result; + } + + @java.lang.Override + public Builder clone() { + return super.clone(); + } + @java.lang.Override + public Builder setField( + com.google.protobuf.Descriptors.FieldDescriptor field, + java.lang.Object value) { + return super.setField(field, value); + } + @java.lang.Override + public Builder clearField( + com.google.protobuf.Descriptors.FieldDescriptor field) { + return super.clearField(field); + } + @java.lang.Override + public Builder clearOneof( + com.google.protobuf.Descriptors.OneofDescriptor oneof) { + return super.clearOneof(oneof); + } + @java.lang.Override + public Builder setRepeatedField( + com.google.protobuf.Descriptors.FieldDescriptor field, + int index, java.lang.Object value) { + return super.setRepeatedField(field, index, value); + } + @java.lang.Override + public Builder addRepeatedField( + com.google.protobuf.Descriptors.FieldDescriptor field, + java.lang.Object value) { + return super.addRepeatedField(field, value); + } + @java.lang.Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof org.mlflow.api.proto.Jobs.JobProgress) { + return mergeFrom((org.mlflow.api.proto.Jobs.JobProgress)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(org.mlflow.api.proto.Jobs.JobProgress other) { + if (other == org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance()) return this; + if (other.hasPhase()) { + bitField0_ |= 0x00000001; + phase_ = other.phase_; + onChanged(); + } + if (other.hasCompleted()) { + setCompleted(other.getCompleted()); + } + if (other.hasTotal()) { + setTotal(other.getTotal()); + } + if (other.hasUnit()) { + bitField0_ |= 0x00000008; + unit_ = other.unit_; + onChanged(); + } + this.mergeUnknownFields(other.unknownFields); + onChanged(); + return this; + } + + @java.lang.Override + public final boolean isInitialized() { + return true; + } + + @java.lang.Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + org.mlflow.api.proto.Jobs.JobProgress parsedMessage = null; + try { + parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + parsedMessage = (org.mlflow.api.proto.Jobs.JobProgress) e.getUnfinishedMessage(); + throw e.unwrapIOException(); + } finally { + if (parsedMessage != null) { + mergeFrom(parsedMessage); + } + } + return this; + } + private int bitField0_; + + private java.lang.Object phase_ = ""; + /** + * optional string phase = 1; + * @return Whether the phase field is set. + */ + public boolean hasPhase() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + * optional string phase = 1; + * @return The phase. + */ + public java.lang.String getPhase() { + java.lang.Object ref = phase_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + phase_ = s; + } + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string phase = 1; + * @return The bytes for phase. + */ + public com.google.protobuf.ByteString + getPhaseBytes() { + java.lang.Object ref = phase_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + phase_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string phase = 1; + * @param value The phase to set. + * @return This builder for chaining. + */ + public Builder setPhase( + java.lang.String value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000001; + phase_ = value; + onChanged(); + return this; + } + /** + * optional string phase = 1; + * @return This builder for chaining. + */ + public Builder clearPhase() { + bitField0_ = (bitField0_ & ~0x00000001); + phase_ = getDefaultInstance().getPhase(); + onChanged(); + return this; + } + /** + * optional string phase = 1; + * @param value The bytes for phase to set. + * @return This builder for chaining. + */ + public Builder setPhaseBytes( + com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000001; + phase_ = value; + onChanged(); + return this; + } + + private long completed_ ; + /** + * optional int64 completed = 2; + * @return Whether the completed field is set. + */ + @java.lang.Override + public boolean hasCompleted() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + * optional int64 completed = 2; + * @return The completed. + */ + @java.lang.Override + public long getCompleted() { + return completed_; + } + /** + * optional int64 completed = 2; + * @param value The completed to set. + * @return This builder for chaining. + */ + public Builder setCompleted(long value) { + bitField0_ |= 0x00000002; + completed_ = value; + onChanged(); + return this; + } + /** + * optional int64 completed = 2; + * @return This builder for chaining. + */ + public Builder clearCompleted() { + bitField0_ = (bitField0_ & ~0x00000002); + completed_ = 0L; + onChanged(); + return this; + } + + private long total_ ; + /** + * optional int64 total = 3; + * @return Whether the total field is set. + */ + @java.lang.Override + public boolean hasTotal() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional int64 total = 3; + * @return The total. + */ + @java.lang.Override + public long getTotal() { + return total_; + } + /** + * optional int64 total = 3; + * @param value The total to set. + * @return This builder for chaining. + */ + public Builder setTotal(long value) { + bitField0_ |= 0x00000004; + total_ = value; + onChanged(); + return this; + } + /** + * optional int64 total = 3; + * @return This builder for chaining. + */ + public Builder clearTotal() { + bitField0_ = (bitField0_ & ~0x00000004); + total_ = 0L; + onChanged(); + return this; + } + + private java.lang.Object unit_ = ""; + /** + * optional string unit = 4; + * @return Whether the unit field is set. + */ + public boolean hasUnit() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional string unit = 4; + * @return The unit. + */ + public java.lang.String getUnit() { + java.lang.Object ref = unit_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + unit_ = s; + } + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string unit = 4; + * @return The bytes for unit. + */ + public com.google.protobuf.ByteString + getUnitBytes() { + java.lang.Object ref = unit_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + unit_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string unit = 4; + * @param value The unit to set. + * @return This builder for chaining. + */ + public Builder setUnit( + java.lang.String value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000008; + unit_ = value; + onChanged(); + return this; + } + /** + * optional string unit = 4; + * @return This builder for chaining. + */ + public Builder clearUnit() { + bitField0_ = (bitField0_ & ~0x00000008); + unit_ = getDefaultInstance().getUnit(); + onChanged(); + return this; + } + /** + * optional string unit = 4; + * @param value The bytes for unit to set. + * @return This builder for chaining. + */ + public Builder setUnitBytes( + com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000008; + unit_ = value; + onChanged(); + return this; + } + @java.lang.Override + public final Builder setUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.setUnknownFields(unknownFields); + } - public final int getNumber() { - return value; - } + @java.lang.Override + public final Builder mergeUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.mergeUnknownFields(unknownFields); + } - /** - * @param value The numeric wire value of the corresponding enum entry. - * @return The enum associated with the given numeric wire value. - * @deprecated Use {@link #forNumber(int)} instead. - */ - @java.lang.Deprecated - public static JobStatus valueOf(int value) { - return forNumber(value); - } - /** - * @param value The numeric wire value of the corresponding enum entry. - * @return The enum associated with the given numeric wire value. - */ - public static JobStatus forNumber(int value) { - switch (value) { - case 0: return JOB_STATUS_UNSPECIFIED; - case 1: return JOB_STATUS_PENDING; - case 2: return JOB_STATUS_IN_PROGRESS; - case 3: return JOB_STATUS_COMPLETED; - case 4: return JOB_STATUS_FAILED; - case 5: return JOB_STATUS_CANCELED; - default: return null; - } + // @@protoc_insertion_point(builder_scope:mlflow.JobProgress) } - public static com.google.protobuf.Internal.EnumLiteMap - internalGetValueMap() { - return internalValueMap; + // @@protoc_insertion_point(class_scope:mlflow.JobProgress) + private static final org.mlflow.api.proto.Jobs.JobProgress DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new org.mlflow.api.proto.Jobs.JobProgress(); } - private static final com.google.protobuf.Internal.EnumLiteMap< - JobStatus> internalValueMap = - new com.google.protobuf.Internal.EnumLiteMap() { - public JobStatus findValueByNumber(int number) { - return JobStatus.forNumber(number); - } - }; - public final com.google.protobuf.Descriptors.EnumValueDescriptor - getValueDescriptor() { - return getDescriptor().getValues().get(ordinal()); - } - public final com.google.protobuf.Descriptors.EnumDescriptor - getDescriptorForType() { - return getDescriptor(); - } - public static final com.google.protobuf.Descriptors.EnumDescriptor - getDescriptor() { - return org.mlflow.api.proto.Jobs.getDescriptor().getEnumTypes().get(0); + public static org.mlflow.api.proto.Jobs.JobProgress getDefaultInstance() { + return DEFAULT_INSTANCE; } - private static final JobStatus[] VALUES = values(); - - public static JobStatus valueOf( - com.google.protobuf.Descriptors.EnumValueDescriptor desc) { - if (desc.getType() != getDescriptor()) { - throw new java.lang.IllegalArgumentException( - "EnumValueDescriptor is not for this type."); + @java.lang.Deprecated public static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public JobProgress parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return new JobProgress(input, extensionRegistry); } - return VALUES[desc.getIndex()]; + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; } - private final int value; + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } - private JobStatus(int value) { - this.value = value; + @java.lang.Override + public org.mlflow.api.proto.Jobs.JobProgress getDefaultInstanceForType() { + return DEFAULT_INSTANCE; } - // @@protoc_insertion_point(enum_scope:mlflow.JobStatus) } public interface JobStateOrBuilder extends @@ -304,6 +1317,81 @@ java.lang.String getMetadataOrDefault( java.lang.String getMetadataOrThrow( java.lang.String key); + + /** + *
+     * Latest best-effort in-flight status message.
+     * 
+ * + * optional string status_message = 4; + * @return Whether the statusMessage field is set. + */ + boolean hasStatusMessage(); + /** + *
+     * Latest best-effort in-flight status message.
+     * 
+ * + * optional string status_message = 4; + * @return The statusMessage. + */ + java.lang.String getStatusMessage(); + /** + *
+     * Latest best-effort in-flight status message.
+     * 
+ * + * optional string status_message = 4; + * @return The bytes for statusMessage. + */ + com.google.protobuf.ByteString + getStatusMessageBytes(); + + /** + *
+     * Latest best-effort structured progress payload.
+     * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + * @return Whether the progressPayload field is set. + */ + boolean hasProgressPayload(); + /** + *
+     * Latest best-effort structured progress payload.
+     * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + * @return The progressPayload. + */ + org.mlflow.api.proto.Jobs.JobProgress getProgressPayload(); + /** + *
+     * Latest best-effort structured progress payload.
+     * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + org.mlflow.api.proto.Jobs.JobProgressOrBuilder getProgressPayloadOrBuilder(); + + /** + *
+     * Timestamp of the latest progress update in milliseconds since epoch.
+     * 
+ * + * optional int64 progress_updated_at = 6; + * @return Whether the progressUpdatedAt field is set. + */ + boolean hasProgressUpdatedAt(); + /** + *
+     * Timestamp of the latest progress update in milliseconds since epoch.
+     * 
+ * + * optional int64 progress_updated_at = 6; + * @return The progressUpdatedAt. + */ + long getProgressUpdatedAt(); } /** *
@@ -325,6 +1413,7 @@ private JobState(com.google.protobuf.GeneratedMessageV3.Builder builder) {
     private JobState() {
       status_ = 0;
       errorMessage_ = "";
+      statusMessage_ = "";
     }
 
     @java.lang.Override
@@ -389,6 +1478,30 @@ private JobState(
                   metadata__.getKey(), metadata__.getValue());
               break;
             }
+            case 34: {
+              com.google.protobuf.ByteString bs = input.readBytes();
+              bitField0_ |= 0x00000004;
+              statusMessage_ = bs;
+              break;
+            }
+            case 42: {
+              org.mlflow.api.proto.Jobs.JobProgress.Builder subBuilder = null;
+              if (((bitField0_ & 0x00000008) != 0)) {
+                subBuilder = progressPayload_.toBuilder();
+              }
+              progressPayload_ = input.readMessage(org.mlflow.api.proto.Jobs.JobProgress.PARSER, extensionRegistry);
+              if (subBuilder != null) {
+                subBuilder.mergeFrom(progressPayload_);
+                progressPayload_ = subBuilder.buildPartial();
+              }
+              bitField0_ |= 0x00000008;
+              break;
+            }
+            case 48: {
+              bitField0_ |= 0x00000010;
+              progressUpdatedAt_ = input.readInt64();
+              break;
+            }
             default: {
               if (!parseUnknownField(
                   input, unknownFields, extensionRegistry, tag)) {
@@ -566,63 +1679,188 @@ public boolean containsMetadata(
       return internalGetMetadata().getMap().containsKey(key);
     }
     /**
-     * Use {@link #getMetadataMap()} instead.
+     * Use {@link #getMetadataMap()} instead.
+     */
+    @java.lang.Override
+    @java.lang.Deprecated
+    public java.util.Map getMetadata() {
+      return getMetadataMap();
+    }
+    /**
+     * 
+     * Additional metadata as key-value pairs.
+     * Can be used to store job-specific state information.
+     * 
+ * + * map<string, string> metadata = 3; + */ + @java.lang.Override + + public java.util.Map getMetadataMap() { + return internalGetMetadata().getMap(); + } + /** + *
+     * Additional metadata as key-value pairs.
+     * Can be used to store job-specific state information.
+     * 
+ * + * map<string, string> metadata = 3; + */ + @java.lang.Override + + public java.lang.String getMetadataOrDefault( + java.lang.String key, + java.lang.String defaultValue) { + if (key == null) { throw new NullPointerException("map key"); } + java.util.Map map = + internalGetMetadata().getMap(); + return map.containsKey(key) ? map.get(key) : defaultValue; + } + /** + *
+     * Additional metadata as key-value pairs.
+     * Can be used to store job-specific state information.
+     * 
+ * + * map<string, string> metadata = 3; + */ + @java.lang.Override + + public java.lang.String getMetadataOrThrow( + java.lang.String key) { + if (key == null) { throw new NullPointerException("map key"); } + java.util.Map map = + internalGetMetadata().getMap(); + if (!map.containsKey(key)) { + throw new java.lang.IllegalArgumentException(); + } + return map.get(key); + } + + public static final int STATUS_MESSAGE_FIELD_NUMBER = 4; + private volatile java.lang.Object statusMessage_; + /** + *
+     * Latest best-effort in-flight status message.
+     * 
+ * + * optional string status_message = 4; + * @return Whether the statusMessage field is set. + */ + @java.lang.Override + public boolean hasStatusMessage() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + *
+     * Latest best-effort in-flight status message.
+     * 
+ * + * optional string status_message = 4; + * @return The statusMessage. + */ + @java.lang.Override + public java.lang.String getStatusMessage() { + java.lang.Object ref = statusMessage_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + statusMessage_ = s; + } + return s; + } + } + /** + *
+     * Latest best-effort in-flight status message.
+     * 
+ * + * optional string status_message = 4; + * @return The bytes for statusMessage. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getStatusMessageBytes() { + java.lang.Object ref = statusMessage_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + statusMessage_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int PROGRESS_PAYLOAD_FIELD_NUMBER = 5; + private org.mlflow.api.proto.Jobs.JobProgress progressPayload_; + /** + *
+     * Latest best-effort structured progress payload.
+     * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + * @return Whether the progressPayload field is set. + */ + @java.lang.Override + public boolean hasProgressPayload() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + *
+     * Latest best-effort structured progress payload.
+     * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + * @return The progressPayload. */ @java.lang.Override - @java.lang.Deprecated - public java.util.Map getMetadata() { - return getMetadataMap(); + public org.mlflow.api.proto.Jobs.JobProgress getProgressPayload() { + return progressPayload_ == null ? org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance() : progressPayload_; } /** *
-     * Additional metadata as key-value pairs.
-     * Can be used to store job-specific state information.
+     * Latest best-effort structured progress payload.
      * 
* - * map<string, string> metadata = 3; + * optional .mlflow.JobProgress progress_payload = 5; */ @java.lang.Override - - public java.util.Map getMetadataMap() { - return internalGetMetadata().getMap(); + public org.mlflow.api.proto.Jobs.JobProgressOrBuilder getProgressPayloadOrBuilder() { + return progressPayload_ == null ? org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance() : progressPayload_; } + + public static final int PROGRESS_UPDATED_AT_FIELD_NUMBER = 6; + private long progressUpdatedAt_; /** *
-     * Additional metadata as key-value pairs.
-     * Can be used to store job-specific state information.
+     * Timestamp of the latest progress update in milliseconds since epoch.
      * 
* - * map<string, string> metadata = 3; + * optional int64 progress_updated_at = 6; + * @return Whether the progressUpdatedAt field is set. */ @java.lang.Override - - public java.lang.String getMetadataOrDefault( - java.lang.String key, - java.lang.String defaultValue) { - if (key == null) { throw new NullPointerException("map key"); } - java.util.Map map = - internalGetMetadata().getMap(); - return map.containsKey(key) ? map.get(key) : defaultValue; + public boolean hasProgressUpdatedAt() { + return ((bitField0_ & 0x00000010) != 0); } /** *
-     * Additional metadata as key-value pairs.
-     * Can be used to store job-specific state information.
+     * Timestamp of the latest progress update in milliseconds since epoch.
      * 
* - * map<string, string> metadata = 3; + * optional int64 progress_updated_at = 6; + * @return The progressUpdatedAt. */ @java.lang.Override - - public java.lang.String getMetadataOrThrow( - java.lang.String key) { - if (key == null) { throw new NullPointerException("map key"); } - java.util.Map map = - internalGetMetadata().getMap(); - if (!map.containsKey(key)) { - throw new java.lang.IllegalArgumentException(); - } - return map.get(key); + public long getProgressUpdatedAt() { + return progressUpdatedAt_; } private byte memoizedIsInitialized = -1; @@ -651,6 +1889,15 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) internalGetMetadata(), MetadataDefaultEntryHolder.defaultEntry, 3); + if (((bitField0_ & 0x00000004) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 4, statusMessage_); + } + if (((bitField0_ & 0x00000008) != 0)) { + output.writeMessage(5, getProgressPayload()); + } + if (((bitField0_ & 0x00000010) != 0)) { + output.writeInt64(6, progressUpdatedAt_); + } unknownFields.writeTo(output); } @@ -677,6 +1924,17 @@ public int getSerializedSize() { size += com.google.protobuf.CodedOutputStream .computeMessageSize(3, metadata__); } + if (((bitField0_ & 0x00000004) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, statusMessage_); + } + if (((bitField0_ & 0x00000008) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(5, getProgressPayload()); + } + if (((bitField0_ & 0x00000010) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(6, progressUpdatedAt_); + } size += unknownFields.getSerializedSize(); memoizedSize = size; return size; @@ -703,6 +1961,21 @@ public boolean equals(final java.lang.Object obj) { } if (!internalGetMetadata().equals( other.internalGetMetadata())) return false; + if (hasStatusMessage() != other.hasStatusMessage()) return false; + if (hasStatusMessage()) { + if (!getStatusMessage() + .equals(other.getStatusMessage())) return false; + } + if (hasProgressPayload() != other.hasProgressPayload()) return false; + if (hasProgressPayload()) { + if (!getProgressPayload() + .equals(other.getProgressPayload())) return false; + } + if (hasProgressUpdatedAt() != other.hasProgressUpdatedAt()) return false; + if (hasProgressUpdatedAt()) { + if (getProgressUpdatedAt() + != other.getProgressUpdatedAt()) return false; + } if (!unknownFields.equals(other.unknownFields)) return false; return true; } @@ -726,6 +1999,19 @@ public int hashCode() { hash = (37 * hash) + METADATA_FIELD_NUMBER; hash = (53 * hash) + internalGetMetadata().hashCode(); } + if (hasStatusMessage()) { + hash = (37 * hash) + STATUS_MESSAGE_FIELD_NUMBER; + hash = (53 * hash) + getStatusMessage().hashCode(); + } + if (hasProgressPayload()) { + hash = (37 * hash) + PROGRESS_PAYLOAD_FIELD_NUMBER; + hash = (53 * hash) + getProgressPayload().hashCode(); + } + if (hasProgressUpdatedAt()) { + hash = (37 * hash) + PROGRESS_UPDATED_AT_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getProgressUpdatedAt()); + } hash = (29 * hash) + unknownFields.hashCode(); memoizedHashCode = hash; return hash; @@ -881,6 +2167,7 @@ private Builder( private void maybeForceBuilderInitialization() { if (com.google.protobuf.GeneratedMessageV3 .alwaysUseFieldBuilders) { + getProgressPayloadFieldBuilder(); } } @java.lang.Override @@ -891,6 +2178,16 @@ public Builder clear() { errorMessage_ = ""; bitField0_ = (bitField0_ & ~0x00000002); internalGetMutableMetadata().clear(); + statusMessage_ = ""; + bitField0_ = (bitField0_ & ~0x00000008); + if (progressPayloadBuilder_ == null) { + progressPayload_ = null; + } else { + progressPayloadBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000010); + progressUpdatedAt_ = 0L; + bitField0_ = (bitField0_ & ~0x00000020); return this; } @@ -929,6 +2226,22 @@ public org.mlflow.api.proto.Jobs.JobState buildPartial() { result.errorMessage_ = errorMessage_; result.metadata_ = internalGetMetadata(); result.metadata_.makeImmutable(); + if (((from_bitField0_ & 0x00000008) != 0)) { + to_bitField0_ |= 0x00000004; + } + result.statusMessage_ = statusMessage_; + if (((from_bitField0_ & 0x00000010) != 0)) { + if (progressPayloadBuilder_ == null) { + result.progressPayload_ = progressPayload_; + } else { + result.progressPayload_ = progressPayloadBuilder_.build(); + } + to_bitField0_ |= 0x00000008; + } + if (((from_bitField0_ & 0x00000020) != 0)) { + result.progressUpdatedAt_ = progressUpdatedAt_; + to_bitField0_ |= 0x00000010; + } result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -988,6 +2301,17 @@ public Builder mergeFrom(org.mlflow.api.proto.Jobs.JobState other) { } internalGetMutableMetadata().mergeFrom( other.internalGetMetadata()); + if (other.hasStatusMessage()) { + bitField0_ |= 0x00000008; + statusMessage_ = other.statusMessage_; + onChanged(); + } + if (other.hasProgressPayload()) { + mergeProgressPayload(other.getProgressPayload()); + } + if (other.hasProgressUpdatedAt()) { + setProgressUpdatedAt(other.getProgressUpdatedAt()); + } this.mergeUnknownFields(other.unknownFields); onChanged(); return this; @@ -1356,6 +2680,325 @@ public Builder putAllMetadata( .putAll(values); return this; } + + private java.lang.Object statusMessage_ = ""; + /** + *
+       * Latest best-effort in-flight status message.
+       * 
+ * + * optional string status_message = 4; + * @return Whether the statusMessage field is set. + */ + public boolean hasStatusMessage() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + *
+       * Latest best-effort in-flight status message.
+       * 
+ * + * optional string status_message = 4; + * @return The statusMessage. + */ + public java.lang.String getStatusMessage() { + java.lang.Object ref = statusMessage_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (bs.isValidUtf8()) { + statusMessage_ = s; + } + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * Latest best-effort in-flight status message.
+       * 
+ * + * optional string status_message = 4; + * @return The bytes for statusMessage. + */ + public com.google.protobuf.ByteString + getStatusMessageBytes() { + java.lang.Object ref = statusMessage_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + statusMessage_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * Latest best-effort in-flight status message.
+       * 
+ * + * optional string status_message = 4; + * @param value The statusMessage to set. + * @return This builder for chaining. + */ + public Builder setStatusMessage( + java.lang.String value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000008; + statusMessage_ = value; + onChanged(); + return this; + } + /** + *
+       * Latest best-effort in-flight status message.
+       * 
+ * + * optional string status_message = 4; + * @return This builder for chaining. + */ + public Builder clearStatusMessage() { + bitField0_ = (bitField0_ & ~0x00000008); + statusMessage_ = getDefaultInstance().getStatusMessage(); + onChanged(); + return this; + } + /** + *
+       * Latest best-effort in-flight status message.
+       * 
+ * + * optional string status_message = 4; + * @param value The bytes for statusMessage to set. + * @return This builder for chaining. + */ + public Builder setStatusMessageBytes( + com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000008; + statusMessage_ = value; + onChanged(); + return this; + } + + private org.mlflow.api.proto.Jobs.JobProgress progressPayload_; + private com.google.protobuf.SingleFieldBuilderV3< + org.mlflow.api.proto.Jobs.JobProgress, org.mlflow.api.proto.Jobs.JobProgress.Builder, org.mlflow.api.proto.Jobs.JobProgressOrBuilder> progressPayloadBuilder_; + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + * @return Whether the progressPayload field is set. + */ + public boolean hasProgressPayload() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + * @return The progressPayload. + */ + public org.mlflow.api.proto.Jobs.JobProgress getProgressPayload() { + if (progressPayloadBuilder_ == null) { + return progressPayload_ == null ? org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance() : progressPayload_; + } else { + return progressPayloadBuilder_.getMessage(); + } + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + public Builder setProgressPayload(org.mlflow.api.proto.Jobs.JobProgress value) { + if (progressPayloadBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + progressPayload_ = value; + onChanged(); + } else { + progressPayloadBuilder_.setMessage(value); + } + bitField0_ |= 0x00000010; + return this; + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + public Builder setProgressPayload( + org.mlflow.api.proto.Jobs.JobProgress.Builder builderForValue) { + if (progressPayloadBuilder_ == null) { + progressPayload_ = builderForValue.build(); + onChanged(); + } else { + progressPayloadBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000010; + return this; + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + public Builder mergeProgressPayload(org.mlflow.api.proto.Jobs.JobProgress value) { + if (progressPayloadBuilder_ == null) { + if (((bitField0_ & 0x00000010) != 0) && + progressPayload_ != null && + progressPayload_ != org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance()) { + progressPayload_ = + org.mlflow.api.proto.Jobs.JobProgress.newBuilder(progressPayload_).mergeFrom(value).buildPartial(); + } else { + progressPayload_ = value; + } + onChanged(); + } else { + progressPayloadBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000010; + return this; + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + public Builder clearProgressPayload() { + if (progressPayloadBuilder_ == null) { + progressPayload_ = null; + onChanged(); + } else { + progressPayloadBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000010); + return this; + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + public org.mlflow.api.proto.Jobs.JobProgress.Builder getProgressPayloadBuilder() { + bitField0_ |= 0x00000010; + onChanged(); + return getProgressPayloadFieldBuilder().getBuilder(); + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + public org.mlflow.api.proto.Jobs.JobProgressOrBuilder getProgressPayloadOrBuilder() { + if (progressPayloadBuilder_ != null) { + return progressPayloadBuilder_.getMessageOrBuilder(); + } else { + return progressPayload_ == null ? + org.mlflow.api.proto.Jobs.JobProgress.getDefaultInstance() : progressPayload_; + } + } + /** + *
+       * Latest best-effort structured progress payload.
+       * 
+ * + * optional .mlflow.JobProgress progress_payload = 5; + */ + private com.google.protobuf.SingleFieldBuilderV3< + org.mlflow.api.proto.Jobs.JobProgress, org.mlflow.api.proto.Jobs.JobProgress.Builder, org.mlflow.api.proto.Jobs.JobProgressOrBuilder> + getProgressPayloadFieldBuilder() { + if (progressPayloadBuilder_ == null) { + progressPayloadBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< + org.mlflow.api.proto.Jobs.JobProgress, org.mlflow.api.proto.Jobs.JobProgress.Builder, org.mlflow.api.proto.Jobs.JobProgressOrBuilder>( + getProgressPayload(), + getParentForChildren(), + isClean()); + progressPayload_ = null; + } + return progressPayloadBuilder_; + } + + private long progressUpdatedAt_ ; + /** + *
+       * Timestamp of the latest progress update in milliseconds since epoch.
+       * 
+ * + * optional int64 progress_updated_at = 6; + * @return Whether the progressUpdatedAt field is set. + */ + @java.lang.Override + public boolean hasProgressUpdatedAt() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + *
+       * Timestamp of the latest progress update in milliseconds since epoch.
+       * 
+ * + * optional int64 progress_updated_at = 6; + * @return The progressUpdatedAt. + */ + @java.lang.Override + public long getProgressUpdatedAt() { + return progressUpdatedAt_; + } + /** + *
+       * Timestamp of the latest progress update in milliseconds since epoch.
+       * 
+ * + * optional int64 progress_updated_at = 6; + * @param value The progressUpdatedAt to set. + * @return This builder for chaining. + */ + public Builder setProgressUpdatedAt(long value) { + bitField0_ |= 0x00000020; + progressUpdatedAt_ = value; + onChanged(); + return this; + } + /** + *
+       * Timestamp of the latest progress update in milliseconds since epoch.
+       * 
+ * + * optional int64 progress_updated_at = 6; + * @return This builder for chaining. + */ + public Builder clearProgressUpdatedAt() { + bitField0_ = (bitField0_ & ~0x00000020); + progressUpdatedAt_ = 0L; + onChanged(); + return this; + } @java.lang.Override public final Builder setUnknownFields( final com.google.protobuf.UnknownFieldSet unknownFields) { @@ -1409,6 +3052,11 @@ public org.mlflow.api.proto.Jobs.JobState getDefaultInstanceForType() { } + private static final com.google.protobuf.Descriptors.Descriptor + internal_static_mlflow_JobProgress_descriptor; + private static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_mlflow_JobProgress_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_mlflow_JobState_descriptor; private static final @@ -1429,28 +3077,39 @@ public org.mlflow.api.proto.Jobs.JobState getDefaultInstanceForType() { static { java.lang.String[] descriptorData = { "\n\njobs.proto\022\006mlflow\032\025scalapb/scalapb.pr" + - "oto\"\247\001\n\010JobState\022!\n\006status\030\001 \001(\0162\021.mlflo" + - "w.JobStatus\022\025\n\rerror_message\030\002 \001(\t\0220\n\010me" + - "tadata\030\003 \003(\0132\036.mlflow.JobState.MetadataE" + - "ntry\032/\n\rMetadataEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005va" + - "lue\030\002 \001(\t:\0028\001*\245\001\n\tJobStatus\022\032\n\026JOB_STATU" + - "S_UNSPECIFIED\020\000\022\026\n\022JOB_STATUS_PENDING\020\001\022" + - "\032\n\026JOB_STATUS_IN_PROGRESS\020\002\022\030\n\024JOB_STATU" + - "S_COMPLETED\020\003\022\025\n\021JOB_STATUS_FAILED\020\004\022\027\n\023" + - "JOB_STATUS_CANCELED\020\005B\036\n\024org.mlflow.api." + - "proto\220\001\001\342?\002\020\001" + "oto\"L\n\013JobProgress\022\r\n\005phase\030\001 \001(\t\022\021\n\tcom" + + "pleted\030\002 \001(\003\022\r\n\005total\030\003 \001(\003\022\014\n\004unit\030\004 \001(" + + "\t\"\213\002\n\010JobState\022!\n\006status\030\001 \001(\0162\021.mlflow." + + "JobStatus\022\025\n\rerror_message\030\002 \001(\t\0220\n\010meta" + + "data\030\003 \003(\0132\036.mlflow.JobState.MetadataEnt" + + "ry\022\026\n\016status_message\030\004 \001(\t\022-\n\020progress_p" + + "ayload\030\005 \001(\0132\023.mlflow.JobProgress\022\033\n\023pro" + + "gress_updated_at\030\006 \001(\003\032/\n\rMetadataEntry\022" + + "\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001*\304\001\n\tJobS" + + "tatus\022\032\n\026JOB_STATUS_UNSPECIFIED\020\000\022\026\n\022JOB" + + "_STATUS_PENDING\020\001\022\032\n\026JOB_STATUS_IN_PROGR" + + "ESS\020\002\022\030\n\024JOB_STATUS_COMPLETED\020\003\022\025\n\021JOB_S" + + "TATUS_FAILED\020\004\022\027\n\023JOB_STATUS_CANCELED\020\005\022" + + "\035\n\031JOB_STATUS_NEEDS_RECOVERY\020\006B\036\n\024org.ml" + + "flow.api.proto\220\001\001\342?\002\020\001" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, new com.google.protobuf.Descriptors.FileDescriptor[] { org.mlflow.scalapb_interface.Scalapb.getDescriptor(), }); - internal_static_mlflow_JobState_descriptor = + internal_static_mlflow_JobProgress_descriptor = getDescriptor().getMessageTypes().get(0); + internal_static_mlflow_JobProgress_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_mlflow_JobProgress_descriptor, + new java.lang.String[] { "Phase", "Completed", "Total", "Unit", }); + internal_static_mlflow_JobState_descriptor = + getDescriptor().getMessageTypes().get(1); internal_static_mlflow_JobState_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_mlflow_JobState_descriptor, - new java.lang.String[] { "Status", "ErrorMessage", "Metadata", }); + new java.lang.String[] { "Status", "ErrorMessage", "Metadata", "StatusMessage", "ProgressPayload", "ProgressUpdatedAt", }); internal_static_mlflow_JobState_MetadataEntry_descriptor = internal_static_mlflow_JobState_descriptor.getNestedTypes().get(0); internal_static_mlflow_JobState_MetadataEntry_fieldAccessorTable = new diff --git a/mlflow/protos/jobs.proto b/mlflow/protos/jobs.proto index 29b77e7c83968..72bb76ec53643 100644 --- a/mlflow/protos/jobs.proto +++ b/mlflow/protos/jobs.proto @@ -27,6 +27,17 @@ enum JobStatus { // Job was canceled by user. JOB_STATUS_CANCELED = 5; + + // Job backend work may still exist, but the current watcher is unresponsive. + JOB_STATUS_NEEDS_RECOVERY = 6; +} + +// Structured best-effort progress payload for a running job. +message JobProgress { + optional string phase = 1; + optional int64 completed = 2; + optional int64 total = 3; + optional string unit = 4; } // Generic job state message combining status with metadata. @@ -42,4 +53,13 @@ message JobState { // Additional metadata as key-value pairs. // Can be used to store job-specific state information. map metadata = 3; + + // Latest best-effort in-flight status message. + optional string status_message = 4; + + // Latest best-effort structured progress payload. + optional JobProgress progress_payload = 5; + + // Timestamp of the latest progress update in milliseconds since epoch. + optional int64 progress_updated_at = 6; } diff --git a/mlflow/protos/jobs_pb2.py b/mlflow/protos/jobs_pb2.py index c38a4edac34df..f0f1f134e7088 100644 --- a/mlflow/protos/jobs_pb2.py +++ b/mlflow/protos/jobs_pb2.py @@ -19,7 +19,7 @@ from .scalapb import scalapb_pb2 as scalapb_dot_scalapb__pb2 - DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\njobs.proto\x12\x06mlflow\x1a\x15scalapb/scalapb.proto\"\xa7\x01\n\x08JobState\x12!\n\x06status\x18\x01 \x01(\x0e\x32\x11.mlflow.JobStatus\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x30\n\x08metadata\x18\x03 \x03(\x0b\x32\x1e.mlflow.JobState.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xa5\x01\n\tJobStatus\x12\x1a\n\x16JOB_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12JOB_STATUS_PENDING\x10\x01\x12\x1a\n\x16JOB_STATUS_IN_PROGRESS\x10\x02\x12\x18\n\x14JOB_STATUS_COMPLETED\x10\x03\x12\x15\n\x11JOB_STATUS_FAILED\x10\x04\x12\x17\n\x13JOB_STATUS_CANCELED\x10\x05\x42\x1e\n\x14org.mlflow.api.proto\x90\x01\x01\xe2?\x02\x10\x01') + DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\njobs.proto\x12\x06mlflow\x1a\x15scalapb/scalapb.proto\"L\n\x0bJobProgress\x12\r\n\x05phase\x18\x01 \x01(\t\x12\x11\n\tcompleted\x18\x02 \x01(\x03\x12\r\n\x05total\x18\x03 \x01(\x03\x12\x0c\n\x04unit\x18\x04 \x01(\t\"\x8b\x02\n\x08JobState\x12!\n\x06status\x18\x01 \x01(\x0e\x32\x11.mlflow.JobStatus\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x30\n\x08metadata\x18\x03 \x03(\x0b\x32\x1e.mlflow.JobState.MetadataEntry\x12\x16\n\x0estatus_message\x18\x04 \x01(\t\x12-\n\x10progress_payload\x18\x05 \x01(\x0b\x32\x13.mlflow.JobProgress\x12\x1b\n\x13progress_updated_at\x18\x06 \x01(\x03\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xc4\x01\n\tJobStatus\x12\x1a\n\x16JOB_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12JOB_STATUS_PENDING\x10\x01\x12\x1a\n\x16JOB_STATUS_IN_PROGRESS\x10\x02\x12\x18\n\x14JOB_STATUS_COMPLETED\x10\x03\x12\x15\n\x11JOB_STATUS_FAILED\x10\x04\x12\x17\n\x13JOB_STATUS_CANCELED\x10\x05\x12\x1d\n\x19JOB_STATUS_NEEDS_RECOVERY\x10\x06\x42\x1e\n\x14org.mlflow.api.proto\x90\x01\x01\xe2?\x02\x10\x01') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -29,12 +29,14 @@ _globals['DESCRIPTOR']._serialized_options = b'\n\024org.mlflow.api.proto\220\001\001\342?\002\020\001' _globals['_JOBSTATE_METADATAENTRY']._loaded_options = None _globals['_JOBSTATE_METADATAENTRY']._serialized_options = b'8\001' - _globals['_JOBSTATUS']._serialized_start=216 - _globals['_JOBSTATUS']._serialized_end=381 - _globals['_JOBSTATE']._serialized_start=46 - _globals['_JOBSTATE']._serialized_end=213 - _globals['_JOBSTATE_METADATAENTRY']._serialized_start=166 - _globals['_JOBSTATE_METADATAENTRY']._serialized_end=213 + _globals['_JOBSTATUS']._serialized_start=394 + _globals['_JOBSTATUS']._serialized_end=590 + _globals['_JOBPROGRESS']._serialized_start=45 + _globals['_JOBPROGRESS']._serialized_end=121 + _globals['_JOBSTATE']._serialized_start=124 + _globals['_JOBSTATE']._serialized_end=391 + _globals['_JOBSTATE_METADATAENTRY']._serialized_start=344 + _globals['_JOBSTATE_METADATAENTRY']._serialized_end=391 # @@protoc_insertion_point(module_scope) else: @@ -56,7 +58,7 @@ from .scalapb import scalapb_pb2 as scalapb_dot_scalapb__pb2 - DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\njobs.proto\x12\x06mlflow\x1a\x15scalapb/scalapb.proto\"\xa7\x01\n\x08JobState\x12!\n\x06status\x18\x01 \x01(\x0e\x32\x11.mlflow.JobStatus\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x30\n\x08metadata\x18\x03 \x03(\x0b\x32\x1e.mlflow.JobState.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xa5\x01\n\tJobStatus\x12\x1a\n\x16JOB_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12JOB_STATUS_PENDING\x10\x01\x12\x1a\n\x16JOB_STATUS_IN_PROGRESS\x10\x02\x12\x18\n\x14JOB_STATUS_COMPLETED\x10\x03\x12\x15\n\x11JOB_STATUS_FAILED\x10\x04\x12\x17\n\x13JOB_STATUS_CANCELED\x10\x05\x42\x1e\n\x14org.mlflow.api.proto\x90\x01\x01\xe2?\x02\x10\x01') + DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\njobs.proto\x12\x06mlflow\x1a\x15scalapb/scalapb.proto\"L\n\x0bJobProgress\x12\r\n\x05phase\x18\x01 \x01(\t\x12\x11\n\tcompleted\x18\x02 \x01(\x03\x12\r\n\x05total\x18\x03 \x01(\x03\x12\x0c\n\x04unit\x18\x04 \x01(\t\"\x8b\x02\n\x08JobState\x12!\n\x06status\x18\x01 \x01(\x0e\x32\x11.mlflow.JobStatus\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x30\n\x08metadata\x18\x03 \x03(\x0b\x32\x1e.mlflow.JobState.MetadataEntry\x12\x16\n\x0estatus_message\x18\x04 \x01(\t\x12-\n\x10progress_payload\x18\x05 \x01(\x0b\x32\x13.mlflow.JobProgress\x12\x1b\n\x13progress_updated_at\x18\x06 \x01(\x03\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xc4\x01\n\tJobStatus\x12\x1a\n\x16JOB_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12JOB_STATUS_PENDING\x10\x01\x12\x1a\n\x16JOB_STATUS_IN_PROGRESS\x10\x02\x12\x18\n\x14JOB_STATUS_COMPLETED\x10\x03\x12\x15\n\x11JOB_STATUS_FAILED\x10\x04\x12\x17\n\x13JOB_STATUS_CANCELED\x10\x05\x12\x1d\n\x19JOB_STATUS_NEEDS_RECOVERY\x10\x06\x42\x1e\n\x14org.mlflow.api.proto\x90\x01\x01\xe2?\x02\x10\x01') _JOBSTATUS = DESCRIPTOR.enum_types_by_name['JobStatus'] JobStatus = enum_type_wrapper.EnumTypeWrapper(_JOBSTATUS) @@ -66,10 +68,19 @@ JOB_STATUS_COMPLETED = 3 JOB_STATUS_FAILED = 4 JOB_STATUS_CANCELED = 5 + JOB_STATUS_NEEDS_RECOVERY = 6 + _JOBPROGRESS = DESCRIPTOR.message_types_by_name['JobProgress'] _JOBSTATE = DESCRIPTOR.message_types_by_name['JobState'] _JOBSTATE_METADATAENTRY = _JOBSTATE.nested_types_by_name['MetadataEntry'] + JobProgress = _reflection.GeneratedProtocolMessageType('JobProgress', (_message.Message,), { + 'DESCRIPTOR' : _JOBPROGRESS, + '__module__' : 'jobs_pb2' + # @@protoc_insertion_point(class_scope:mlflow.JobProgress) + }) + _sym_db.RegisterMessage(JobProgress) + JobState = _reflection.GeneratedProtocolMessageType('JobState', (_message.Message,), { 'MetadataEntry' : _reflection.GeneratedProtocolMessageType('MetadataEntry', (_message.Message,), { @@ -91,11 +102,13 @@ DESCRIPTOR._serialized_options = b'\n\024org.mlflow.api.proto\220\001\001\342?\002\020\001' _JOBSTATE_METADATAENTRY._options = None _JOBSTATE_METADATAENTRY._serialized_options = b'8\001' - _JOBSTATUS._serialized_start=216 - _JOBSTATUS._serialized_end=381 - _JOBSTATE._serialized_start=46 - _JOBSTATE._serialized_end=213 - _JOBSTATE_METADATAENTRY._serialized_start=166 - _JOBSTATE_METADATAENTRY._serialized_end=213 + _JOBSTATUS._serialized_start=394 + _JOBSTATUS._serialized_end=590 + _JOBPROGRESS._serialized_start=45 + _JOBPROGRESS._serialized_end=121 + _JOBSTATE._serialized_start=124 + _JOBSTATE._serialized_end=391 + _JOBSTATE_METADATAENTRY._serialized_start=344 + _JOBSTATE_METADATAENTRY._serialized_end=391 # @@protoc_insertion_point(module_scope) diff --git a/mlflow/protos/jobs_pb2.pyi b/mlflow/protos/jobs_pb2.pyi index 4c51ecff92a99..f509578493205 100644 --- a/mlflow/protos/jobs_pb2.pyi +++ b/mlflow/protos/jobs_pb2.pyi @@ -15,15 +15,29 @@ class JobStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): JOB_STATUS_COMPLETED: _ClassVar[JobStatus] JOB_STATUS_FAILED: _ClassVar[JobStatus] JOB_STATUS_CANCELED: _ClassVar[JobStatus] + JOB_STATUS_NEEDS_RECOVERY: _ClassVar[JobStatus] JOB_STATUS_UNSPECIFIED: JobStatus JOB_STATUS_PENDING: JobStatus JOB_STATUS_IN_PROGRESS: JobStatus JOB_STATUS_COMPLETED: JobStatus JOB_STATUS_FAILED: JobStatus JOB_STATUS_CANCELED: JobStatus +JOB_STATUS_NEEDS_RECOVERY: JobStatus + +class JobProgress(_message.Message): + __slots__ = ("phase", "completed", "total", "unit") + PHASE_FIELD_NUMBER: _ClassVar[int] + COMPLETED_FIELD_NUMBER: _ClassVar[int] + TOTAL_FIELD_NUMBER: _ClassVar[int] + UNIT_FIELD_NUMBER: _ClassVar[int] + phase: str + completed: int + total: int + unit: str + def __init__(self, phase: _Optional[str] = ..., completed: _Optional[int] = ..., total: _Optional[int] = ..., unit: _Optional[str] = ...) -> None: ... class JobState(_message.Message): - __slots__ = ("status", "error_message", "metadata") + __slots__ = ("status", "error_message", "metadata", "status_message", "progress_payload", "progress_updated_at") class MetadataEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -34,7 +48,13 @@ class JobState(_message.Message): STATUS_FIELD_NUMBER: _ClassVar[int] ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] METADATA_FIELD_NUMBER: _ClassVar[int] + STATUS_MESSAGE_FIELD_NUMBER: _ClassVar[int] + PROGRESS_PAYLOAD_FIELD_NUMBER: _ClassVar[int] + PROGRESS_UPDATED_AT_FIELD_NUMBER: _ClassVar[int] status: JobStatus error_message: str metadata: _containers.ScalarMap[str, str] - def __init__(self, status: _Optional[_Union[JobStatus, str]] = ..., error_message: _Optional[str] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + status_message: str + progress_payload: JobProgress + progress_updated_at: int + def __init__(self, status: _Optional[_Union[JobStatus, str]] = ..., error_message: _Optional[str] = ..., metadata: _Optional[_Mapping[str, str]] = ..., status_message: _Optional[str] = ..., progress_payload: _Optional[_Union[JobProgress, _Mapping]] = ..., progress_updated_at: _Optional[int] = ...) -> None: ... diff --git a/mlflow/server/handlers.py b/mlflow/server/handlers.py index b1f87b8202569..33dc656de9a2c 100644 --- a/mlflow/server/handlers.py +++ b/mlflow/server/handlers.py @@ -4276,7 +4276,13 @@ def _get_job(job_id): return jsonify({ "status": str(job.status), "result": job.parsed_result, + "error_message": job.error_message, "status_details": job.status_details, + "status_message": job.status_message, + "progress_payload": ( + job.progress_payload.to_dict() if job.progress_payload is not None else None + ), + "progress_updated_at": job.progress_updated_at, }) @@ -4289,6 +4295,13 @@ def _cancel_job(job_id): return jsonify({ "status": str(job.status), "result": job.parsed_result, + "error_message": job.error_message, + "status_details": job.status_details, + "status_message": job.status_message, + "progress_payload": ( + job.progress_payload.to_dict() if job.progress_payload is not None else None + ), + "progress_updated_at": job.progress_updated_at, }) @@ -6620,6 +6633,12 @@ def _build_prompt_optimization_job_from_entity(job_entity): optimization_job = PromptOptimizationJobProto() optimization_job.job_id = job_entity.job_id optimization_job.state.status = job_entity.status.to_proto() + if job_entity.status_message: + optimization_job.state.status_message = job_entity.status_message + if job_entity.progress_payload: + optimization_job.state.progress_payload.CopyFrom(job_entity.progress_payload.to_proto()) + if job_entity.progress_updated_at is not None: + optimization_job.state.progress_updated_at = job_entity.progress_updated_at optimization_job.creation_timestamp_ms = job_entity.creation_time params = json.loads(job_entity.params) @@ -6663,9 +6682,13 @@ def _build_prompt_optimization_job_from_entity(job_entity): if isinstance(result, dict) and result.get("optimized_prompt_uri"): optimization_job.optimized_prompt_uri = result["optimized_prompt_uri"] - # If job failed, add error message to state - if job_entity.status.name == "FAILED" and job_entity.parsed_result: - optimization_job.state.error_message = str(job_entity.parsed_result) + # If job failed, prefer the dedicated error_message field and fall back to the + # legacy parsed result behavior used by existing jobs. + if job_entity.status.name in {"FAILED", "TIMEOUT"}: + if job_entity.error_message: + optimization_job.state.error_message = job_entity.error_message + elif job_entity.parsed_result: + optimization_job.state.error_message = str(job_entity.parsed_result) return optimization_job diff --git a/mlflow/server/job_api.py b/mlflow/server/job_api.py index e73df0751ba25..03ae0a2f52c76 100644 --- a/mlflow/server/job_api.py +++ b/mlflow/server/job_api.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from mlflow.entities._job import Job as JobEntity +from mlflow.entities._job import JobProgress from mlflow.entities._job_status import JobStatus from mlflow.exceptions import MlflowException @@ -27,9 +28,13 @@ class Job(BaseModel): timeout: float | None status: JobStatus result: Any + error_message: str | None = None retry_count: int last_update_time: int status_details: dict[str, Any] | None = None + status_message: str | None = None + progress_payload: dict[str, Any] | None = None + progress_updated_at: int | None = None @classmethod def from_job_entity(cls, job: JobEntity) -> "Job": @@ -41,9 +46,17 @@ def from_job_entity(cls, job: JobEntity) -> "Job": timeout=job.timeout, status=job.status, result=job.parsed_result, + error_message=job.error_message, retry_count=job.retry_count, last_update_time=job.last_update_time, status_details=job.status_details, + status_message=job.status_message, + progress_payload=( + job.progress_payload.to_dict() + if isinstance(job.progress_payload, JobProgress) + else None + ), + progress_updated_at=job.progress_updated_at, ) diff --git a/mlflow/server/jobs/progress.py b/mlflow/server/jobs/progress.py index 0da96718d23a1..7dc75709bc2e6 100644 --- a/mlflow/server/jobs/progress.py +++ b/mlflow/server/jobs/progress.py @@ -2,6 +2,8 @@ from typing import Any +from mlflow.store.jobs.abstract_store import JobTerminalStateUpdateException + _job_tracker: "JobTracker | NoOpTracker | None" = None @@ -15,7 +17,12 @@ def update(self, status_details: dict[str, Any]) -> None: from mlflow.server.handlers import _get_job_store job_store = _get_job_store() - job_store.update_status_details(self.job_id, status_details) + try: + job_store.update_status_details(self.job_id, status_details) + except JobTerminalStateUpdateException: + # Progress updates are best-effort. Once the job is already terminal, + # late heartbeats should be ignored rather than turning into failures. + pass class NoOpTracker: diff --git a/mlflow/server/jobs/utils.py b/mlflow/server/jobs/utils.py index acde080d094e5..765a9be4b7058 100644 --- a/mlflow/server/jobs/utils.py +++ b/mlflow/server/jobs/utils.py @@ -645,13 +645,13 @@ def _enqueue_unfinished_jobs(server_launching_timestamp: int) -> None: for workspace_ctx in _workspace_contexts_for_recovery(): with workspace_ctx as workspace: unfinished_jobs = job_store.list_jobs( - statuses=[JobStatus.PENDING, JobStatus.RUNNING], + statuses=[JobStatus.PENDING, JobStatus.RUNNING, JobStatus.NEEDS_RECOVERY], # filter out jobs created after the server is launched. end_timestamp=server_launching_timestamp, ) for job in unfinished_jobs: - if job.status == JobStatus.RUNNING: + if job.status in {JobStatus.RUNNING, JobStatus.NEEDS_RECOVERY}: job_store.reset_job(job.job_id) # reset the job status to PENDING params = json.loads(job.params) diff --git a/mlflow/server/js/src/common/hooks/useGetTrackingServerJobStatus.tsx b/mlflow/server/js/src/common/hooks/useGetTrackingServerJobStatus.tsx index f32ad619c3eba..217ee43eb242b 100644 --- a/mlflow/server/js/src/common/hooks/useGetTrackingServerJobStatus.tsx +++ b/mlflow/server/js/src/common/hooks/useGetTrackingServerJobStatus.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { JobProgressMetadata } from '../types'; import { fetchAPI, getAjaxUrl } from '../utils/FetchUtils'; import type { QueryFunctionContext, UseQueryOptions } from '../utils/reactQueryHooks'; import { useQuery } from '../utils/reactQueryHooks'; @@ -10,16 +11,29 @@ export enum TrackingJobStatus { PENDING = 'PENDING', SUCCEEDED = 'SUCCEEDED', FAILED = 'FAILED', - CANCELLED = 'CANCELLED', + CANCELED = 'CANCELED', TIMEOUT = 'TIMEOUT', + NEEDS_RECOVERY = 'NEEDS_RECOVERY', } +export const isTrackingJobInFlight = (status: TrackingJobStatus | undefined): boolean => + status === TrackingJobStatus.PENDING || + status === TrackingJobStatus.RUNNING || + status === TrackingJobStatus.NEEDS_RECOVERY; + +export const isTrackingJobTerminal = (status: TrackingJobStatus | undefined): boolean => + status === TrackingJobStatus.SUCCEEDED || + status === TrackingJobStatus.FAILED || + status === TrackingJobStatus.TIMEOUT || + status === TrackingJobStatus.CANCELED; + export type TrackingJobQueryResult = ( | { status: | TrackingJobStatus.RUNNING | TrackingJobStatus.PENDING - | TrackingJobStatus.CANCELLED + | TrackingJobStatus.NEEDS_RECOVERY + | TrackingJobStatus.CANCELED | TrackingJobStatus.TIMEOUT; } | { @@ -32,9 +46,10 @@ export type TrackingJobQueryResult = ( // In case of failure, the result is the error message result: string; } -) & { - jobId: string; -}; +) & + JobProgressMetadata & { + jobId: string; + }; type QueryKey = [typeof GET_JOB_DATA_QUERY_KEY, string[] | undefined]; @@ -42,8 +57,8 @@ const queryFn = async ({ queryKey: [, jobIds] }: QueryFunctionContext) const responsesData = await Promise.all( (jobIds ?? []).map(async (jobId) => { const responseData = await fetchAPI(getAjaxUrl(`ajax-api/3.0/jobs/${jobId}`)); - const { status, result } = responseData; - return { jobId, status, result }; + const { status, result, error_message, status_message, progress_payload, progress_updated_at } = responseData; + return { jobId, status, result, error_message, status_message, progress_payload, progress_updated_at }; }), ); return responsesData; @@ -66,10 +81,7 @@ export const useGetTrackingServerJobStatus = ( // Determine if any of the jobs are still running const areJobsRunning = isEnabled && - (queryResult.isLoading || - queryResult.data?.some( - (response) => response.status === TrackingJobStatus.PENDING || response.status === TrackingJobStatus.RUNNING, - )); + (queryResult.isLoading || queryResult.data?.some((response) => isTrackingJobInFlight(response.status))); const jobResults = useMemo( () => diff --git a/mlflow/server/js/src/common/types.ts b/mlflow/server/js/src/common/types.ts index 2d8c0585d456b..1bce4afcca09c 100644 --- a/mlflow/server/js/src/common/types.ts +++ b/mlflow/server/js/src/common/types.ts @@ -7,3 +7,17 @@ export interface KeyValueEntity { key: string; value: string; } + +export interface JobProgressPayload { + phase?: string; + completed?: number; + total?: number; + unit?: string; +} + +export interface JobProgressMetadata { + error_message?: string | null; + status_message?: string | null; + progress_payload?: JobProgressPayload | null; + progress_updated_at?: number | null; +} diff --git a/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.test.tsx b/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.test.tsx index ad4aa17913a45..eb9b9362bdef2 100644 --- a/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.test.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.test.tsx @@ -51,6 +51,10 @@ describe('useFetchJobStatus', () => { expect(isJobComplete(JobStatus.RUNNING)).toBe(false); }); + test('returns false for NEEDS_RECOVERY status', () => { + expect(isJobComplete(JobStatus.NEEDS_RECOVERY)).toBe(false); + }); + test('returns false for undefined status', () => { expect(isJobComplete(undefined)).toBe(false); }); @@ -169,5 +173,21 @@ describe('useFetchJobStatus', () => { expect(fetchAPI).toHaveBeenCalledTimes(2); }); + + test('fetches needs recovery job status', async () => { + const mockResponse = { + status: JobStatus.NEEDS_RECOVERY, + result: null, + }; + jest.mocked(fetchAPI).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useFetchJobStatus({ jobId: 'job-recovery' }), { wrapper }); + + await waitFor(() => { + expect(result.current.status).toBe(JobStatus.NEEDS_RECOVERY); + expect(result.current.result).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + }); }); }); diff --git a/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.ts b/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.ts index 4654c4abbb88a..294127b725df5 100644 --- a/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.ts +++ b/mlflow/server/js/src/experiment-tracking/components/run-page/hooks/useFetchJobStatus.ts @@ -1,5 +1,6 @@ import { useQuery } from '@databricks/web-shared/query-client'; import { fetchAPI, getAjaxUrl } from '@mlflow/mlflow/src/common/utils/FetchUtils'; +import type { JobProgressMetadata } from '../../../../common/types'; export const FETCH_JOB_STATUS_QUERY_KEY = 'FETCH_JOB_STATUS'; @@ -10,9 +11,10 @@ export enum JobStatus { FAILED = 'FAILED', TIMEOUT = 'TIMEOUT', CANCELED = 'CANCELED', + NEEDS_RECOVERY = 'NEEDS_RECOVERY', } -export interface FetchJobStatusResponse { +export interface FetchJobStatusResponse extends JobProgressMetadata { status: JobStatus; result?: unknown; status_details?: { @@ -31,7 +33,7 @@ export const isJobComplete = (status: JobStatus | undefined): boolean => { ); }; -export interface UseFetchJobStatusResult { +export interface UseFetchJobStatusResult extends JobProgressMetadata { status: JobStatus | undefined; result: unknown; status_details?: { @@ -73,6 +75,10 @@ export const useFetchJobStatus = ({ return { status: data?.status, result: data?.result, + error_message: data?.error_message, + status_message: data?.status_message, + progress_payload: data?.progress_payload, + progress_updated_at: data?.progress_updated_at, status_details: data?.status_details, isLoading, isFetching, diff --git a/mlflow/server/js/src/experiment-tracking/components/run-page/overview/IssueDetectionRunOverview.tsx b/mlflow/server/js/src/experiment-tracking/components/run-page/overview/IssueDetectionRunOverview.tsx index 516cba318238a..86ecc485fecf1 100644 --- a/mlflow/server/js/src/experiment-tracking/components/run-page/overview/IssueDetectionRunOverview.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/run-page/overview/IssueDetectionRunOverview.tsx @@ -44,6 +44,7 @@ export const IssueDetectionRunOverview = ({ const { status: jobStatus, result: rawResult, + error_message: rawErrorMessage, status_details: jobStatusDetails, isLoading: isLoadingJobStatus, error: jobStatusError, @@ -54,7 +55,12 @@ export const IssueDetectionRunOverview = ({ // Parse issue-specific result format from job if available const isFailed = jobStatus === JobStatus.FAILED || jobStatus === JobStatus.TIMEOUT; - const jobErrorMessage = isFailed && typeof rawResult === 'string' ? rawResult : undefined; + const jobErrorMessage = + isFailed && typeof rawErrorMessage === 'string' + ? rawErrorMessage + : isFailed && typeof rawResult === 'string' + ? rawResult + : undefined; const jobResult = !isFailed && typeof rawResult === 'object' && rawResult !== null ? (rawResult as IssueJobResult) : undefined; diff --git a/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.test.tsx b/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.test.tsx index 62430bf6842de..c01e6224bbdd7 100644 --- a/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.test.tsx +++ b/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.test.tsx @@ -415,6 +415,77 @@ describe('useEvaluateTracesAsync', () => { }, 30000); }); + describe('Recovery state polling', () => { + it('should continue polling when a job enters NEEDS_RECOVERY before succeeding', async () => { + const traceId = 'trace-1'; + const experimentId = 'exp-123'; + const jobId = 'job-recovering'; + const serializedScorer = 'mock-serialized-scorer'; + + const mockTrace = createMockTrace(traceId); + const traces = new Map([[traceId, mockTrace]]); + + server.use( + rest.post('ajax-api/3.0/mlflow/traces/search', (_req, res, ctx) => { + return res(ctx.json({ traces: [{ trace_id: traceId }], next_page_token: undefined })); + }), + rest.post('ajax-api/3.0/mlflow/scorer/invoke', (_req, res, ctx) => { + return res(ctx.json({ jobs: [{ job_id: jobId }] })); + }), + ); + + let jobStatusCallCount = 0; + server.use( + rest.get(`ajax-api/3.0/jobs/${jobId}`, (_req, res, ctx) => { + jobStatusCallCount++; + if (jobStatusCallCount === 1) { + return res(ctx.json({ status: 'NEEDS_RECOVERY' })); + } + return res( + ctx.json({ + status: 'SUCCEEDED', + result: { + [traceId]: { + assessments: [ + { + assessment_name: 'test_scorer', + numeric_value: 0.9, + rationale: 'Recovered successfully', + }, + ], + }, + }, + }), + ); + }), + ); + + setupTraceFetchHandlers(server, traces); + + const { result } = renderHook(() => useEvaluateTracesAsync({}), { wrapper }); + + act(() => { + const [evaluateFunction] = result.current; + evaluateFunction({ + itemIds: ['trace-1'], + locations: [{ mlflow_experiment: { experiment_id: experimentId }, type: 'MLFLOW_EXPERIMENT' }], + experimentId, + judgeInstructions: 'Test instructions', + serializedScorer, + }); + }); + + await waitFor(() => { + expect(result.current[1].isLoading).toBe(false); + expect(result.current[1].latestEvaluation).not.toBeNull(); + expect(result.current[1].error).toBeNull(); + }); + + expect(jobStatusCallCount).toBeGreaterThanOrEqual(2); + expect(result.current[1].error).toBeNull(); + }); + }); + describe('Reset Functionality', () => { it('should reset state when reset is called', async () => { const traceId = 'trace-1'; @@ -509,7 +580,7 @@ describe('useEvaluateTracesAsync', () => { }), // Cancel endpoint - fire-and-forget, just acknowledge rest.patch('ajax-api/3.0/jobs/cancel/:jobId', (_req, res, ctx) => { - return res(ctx.json({ status: 'CANCELLED' })); + return res(ctx.json({ status: 'CANCELED' })); }), ); @@ -935,11 +1006,12 @@ describe('useEvaluateTracesAsync', () => { }), ); }), - // Second job fails but has per-trace results for trace-2 and trace-3 + // Second job times out but still returns per-trace results for trace-2 and trace-3 rest.get('ajax-api/3.0/jobs/job-partial-fail', (_req, res, ctx) => { return res( ctx.json({ - status: 'FAILED', + status: 'TIMEOUT', + error_message: 'Evaluation timed out', result: { 'trace-2': { assessments: [{ assessment_name: 'scorer', numeric_value: 0.8 }], diff --git a/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.tsx b/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.tsx index 959bdd62d6167..5cc8408adad2c 100644 --- a/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.tsx +++ b/mlflow/server/js/src/experiment-tracking/pages/experiment-scorers/useEvaluateTracesAsync.tsx @@ -1,12 +1,17 @@ import { fetchAPI, getAjaxUrl } from '../../../common/utils/FetchUtils'; import { useMutation, useQueryClient } from '../../../common/utils/reactQueryHooks'; +import type { JobProgressMetadata } from '../../../common/types'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { FeedbackAssessment, ModelTrace } from '../../../shared/web-shared/model-trace-explorer'; import type { EvaluateTracesParams } from './types'; import { useGetTraceIdsForEvaluation } from './useGetTracesForEvaluation'; import type { JudgeEvaluationResult, SessionJudgeEvaluationResult } from './useEvaluateTraces.common'; import { getMlflowTraceV3ForEvaluation } from './useEvaluateTraces.common'; -import { TrackingJobStatus } from '../../../common/hooks/useGetTrackingServerJobStatus'; +import { + isTrackingJobInFlight, + isTrackingJobTerminal, + TrackingJobStatus, +} from '../../../common/hooks/useGetTrackingServerJobStatus'; import { compact, uniq, zipObject } from 'lodash'; import type { SessionForEvaluation } from './useGetSessionsForEvaluation'; import { useGetSessionsForEvaluation } from './useGetSessionsForEvaluation'; @@ -15,19 +20,27 @@ import { parseJSONSafe } from '../../../common/utils/TagUtils'; const JOB_POLLING_INTERVAL = 1500; -type JobStatusMap = Record; +type JobStatusMap = Record< + string, + JobProgressMetadata & { + status: TrackingJobStatus; + result?: unknown; + } +>; type EvaluateTracesAsyncJobResult = Record< string, { assessments: FeedbackAssessment[]; failures?: { error_code: string; error_message: string }[] } >; +const isInFlightStatus = (status: TrackingJobStatus | undefined): boolean => !status || isTrackingJobInFlight(status); + /** Check if any job is still running or pending */ const isJobsLoading = (jobStatuses: JobStatusMap, jobIds: string[]): boolean => { if (!jobIds.length) return true; // No jobs yet = still loading return jobIds.some((id) => { const status = jobStatuses[id]?.status; - return !status || status === TrackingJobStatus.RUNNING || status === TrackingJobStatus.PENDING; + return isInFlightStatus(status); }); }; @@ -65,12 +78,12 @@ const deriveRequestStatus = (evaluationRequest: ScorerEvaluation): TrackingJobSt const { jobIds, jobStatuses } = evaluationRequest; if (!jobIds.length) return TrackingJobStatus.PENDING; const statuses = jobIds.map((id) => jobStatuses[id]?.status); - if ( - statuses.some((status) => !status || status === TrackingJobStatus.RUNNING || status === TrackingJobStatus.PENDING) - ) { + if (statuses.some((status) => isInFlightStatus(status))) { return statuses.some((status) => status === TrackingJobStatus.RUNNING) ? TrackingJobStatus.RUNNING - : TrackingJobStatus.PENDING; + : statuses.some((status) => status === TrackingJobStatus.NEEDS_RECOVERY) + ? TrackingJobStatus.NEEDS_RECOVERY + : TrackingJobStatus.PENDING; } // All jobs are complete - check if at least one succeeded return statuses.includes(TrackingJobStatus.SUCCEEDED) ? TrackingJobStatus.SUCCEEDED : TrackingJobStatus.FAILED; @@ -97,9 +110,12 @@ const buildResults = (evaluationRequest: ScorerEvaluation): JudgeEvaluationResul for (const jobId of jobIds) { const job = jobStatuses[jobId]; - if (job?.status !== TrackingJobStatus.FAILED) { + if (job?.status !== TrackingJobStatus.FAILED && job?.status !== TrackingJobStatus.TIMEOUT) { continue; } + if (!failedJobError && typeof job.error_message === 'string') { + failedJobError = job.error_message; + } if (!failedJobError && typeof job.result === 'string') { failedJobError = job.result; } @@ -111,7 +127,10 @@ const buildResults = (evaluationRequest: ScorerEvaluation): JudgeEvaluationResul } // Default error for traces without results when some jobs failed - const hasFailedJobs = jobIds.some((id) => jobStatuses[id]?.status === TrackingJobStatus.FAILED); + const hasFailedJobs = jobIds.some( + (id) => + jobStatuses[id]?.status === TrackingJobStatus.FAILED || jobStatuses[id]?.status === TrackingJobStatus.TIMEOUT, + ); const defaultError = hasFailedJobs ? failedJobError || 'Evaluation job failed' : null; if (sessionsData) { @@ -162,8 +181,14 @@ const buildResults = (evaluationRequest: ScorerEvaluation): JudgeEvaluationResul const extractError = (jobStatuses: JobStatusMap, jobIds: string[]): Error => { for (const jobId of jobIds) { const job = jobStatuses[jobId]; - if (job?.status === TrackingJobStatus.FAILED) { - return new Error(typeof job.result === 'string' ? job.result : 'Job failed'); + if (job?.status === TrackingJobStatus.FAILED || job?.status === TrackingJobStatus.TIMEOUT) { + return new Error( + typeof job.error_message === 'string' + ? job.error_message + : typeof job.result === 'string' + ? job.result + : 'Job failed', + ); } } return new Error('Unknown error'); @@ -203,7 +228,7 @@ export const useEvaluateTracesAsync = ({ } for (const jobId of ev.jobIds) { const status = ev.jobStatuses[jobId]?.status; - if (status !== TrackingJobStatus.SUCCEEDED && status !== TrackingJobStatus.FAILED) { + if (!isTrackingJobTerminal(status)) { jobsToPoll.push(jobId); } } @@ -217,9 +242,22 @@ export const useEvaluateTracesAsync = ({ jobsToPoll.map(async (jobId) => { try { const res = await fetchAPI(getAjaxUrl(`ajax-api/3.0/jobs/${jobId}`)); - return { jobId, status: res.status as TrackingJobStatus, result: res.result }; + return { + jobId, + status: res.status as TrackingJobStatus, + result: res.result, + error_message: res.error_message, + status_message: res.status_message, + progress_payload: res.progress_payload, + progress_updated_at: res.progress_updated_at, + }; } catch { - return { jobId, status: TrackingJobStatus.FAILED, result: 'Failed to fetch job status' }; + return { + jobId, + status: TrackingJobStatus.FAILED, + result: 'Failed to fetch job status', + error_message: 'Failed to fetch job status', + }; } }), ); @@ -227,10 +265,11 @@ export const useEvaluateTracesAsync = ({ setEvaluations((prev) => { const next = { ...prev }; // Update job statuses for each evaluation request - for (const { jobId, status, result } of results) { + for (const polledJob of results) { + const { jobId } = polledJob; for (const evaluationRequest of Object.values(next)) { if (evaluationRequest.jobIds.includes(jobId)) { - const newJobStatuses = { ...evaluationRequest.jobStatuses, [jobId]: { status, result } }; + const newJobStatuses = { ...evaluationRequest.jobStatuses, [jobId]: polledJob }; next[evaluationRequest.requestKey] = { ...evaluationRequest, jobStatuses: newJobStatuses, diff --git a/mlflow/store/db_migrations/versions/dc11669786a5_add_job_executor_persistence_model.py b/mlflow/store/db_migrations/versions/dc11669786a5_add_job_executor_persistence_model.py new file mode 100644 index 0000000000000..fcb9f711c35b1 --- /dev/null +++ b/mlflow/store/db_migrations/versions/dc11669786a5_add_job_executor_persistence_model.py @@ -0,0 +1,101 @@ +"""add job executor persistence model + +Create Date: 2026-04-13 14:19:33.487151 + +""" + +import time + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mssql + +# revision identifiers, used by Alembic. +revision = "dc11669786a5" +down_revision = "7d34483879f0" +branch_labels = None +depends_on = None + +_PENDING = 0 +_RUNNING = 1 +_CANCELED = 5 + + +def _get_json_type(): + dialect_name = op.get_bind().dialect.name + if dialect_name == "mssql": + return mssql.JSON + return sa.JSON + + +def _cancel_non_terminal_jobs(): + jobs = sa.table("jobs", sa.column("status"), sa.column("last_update_time")) + op.execute( + jobs + .update() + .where(jobs.c.status.in_([_PENDING, _RUNNING])) + .values(status=_CANCELED, last_update_time=int(time.time() * 1000)) + ) + + +def upgrade(): + json_type = _get_json_type() + + op.create_table( + "scheduler_leases", + sa.Column("lease_key", sa.String(length=255), nullable=False), + sa.Column("acquired_at", sa.BigInteger(), nullable=False), + sa.Column("ttl_seconds", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("lease_key", name="scheduler_leases_pk"), + ) + + op.create_table( + "job_locks", + sa.Column("lock_key", sa.String(length=255), nullable=False), + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("acquired_at", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["job_id"], + ["jobs.id"], + name="fk_job_locks_job_id", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("lock_key", name="job_locks_pk"), + ) + + with op.batch_alter_table("job_locks") as batch_op: + batch_op.create_index("index_job_locks_job_id", ["job_id"], unique=False) + + with op.batch_alter_table("jobs") as batch_op: + batch_op.add_column(sa.Column("executor_backend", sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column("lease_expires_at", sa.BigInteger(), nullable=True)) + batch_op.add_column(sa.Column("status_message", sa.Text(), nullable=True)) + batch_op.add_column(sa.Column("progress_payload", json_type, nullable=True)) + batch_op.add_column(sa.Column("progress_updated_at", sa.BigInteger(), nullable=True)) + batch_op.add_column(sa.Column("token_hash", sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column("scoped_permissions", json_type, nullable=True)) + batch_op.create_index( + "index_jobs_status_lease_expires_at", + ["status", "lease_expires_at"], + unique=False, + ) + + _cancel_non_terminal_jobs() + + +def downgrade(): + with op.batch_alter_table("jobs") as batch_op: + batch_op.drop_index("index_jobs_status_lease_expires_at") + batch_op.drop_column("scoped_permissions") + batch_op.drop_column("token_hash") + batch_op.drop_column("progress_updated_at") + batch_op.drop_column("progress_payload") + batch_op.drop_column("status_message") + batch_op.drop_column("lease_expires_at") + batch_op.drop_column("executor_backend") + + with op.batch_alter_table("job_locks") as batch_op: + batch_op.drop_index("index_job_locks_job_id") + + op.drop_table("job_locks") + op.drop_table("scheduler_leases") diff --git a/mlflow/store/jobs/abstract_store.py b/mlflow/store/jobs/abstract_store.py index aca1a8fdfca07..fde4ba953c606 100644 --- a/mlflow/store/jobs/abstract_store.py +++ b/mlflow/store/jobs/abstract_store.py @@ -3,9 +3,21 @@ from mlflow.entities._job import Job from mlflow.entities._job_status import JobStatus +from mlflow.exceptions import MlflowException +from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE from mlflow.utils.annotations import developer_stable +class JobTerminalStateUpdateException(MlflowException): + """Raised when attempting to update a job that is already terminal.""" + + def __init__(self, job_id: str, status: str): + super().__init__( + f"The Job {job_id} is already finalized with status: {status}, it can't be updated.", + error_code=INVALID_PARAMETER_VALUE, + ) + + @developer_stable class AbstractJobStore(ABC): """ diff --git a/mlflow/store/jobs/sqlalchemy_store.py b/mlflow/store/jobs/sqlalchemy_store.py index f4e974019e046..1ecedb5e5de95 100644 --- a/mlflow/store/jobs/sqlalchemy_store.py +++ b/mlflow/store/jobs/sqlalchemy_store.py @@ -14,7 +14,7 @@ _safe_initialize_tables, create_sqlalchemy_engine_with_retry, ) -from mlflow.store.jobs.abstract_store import AbstractJobStore +from mlflow.store.jobs.abstract_store import AbstractJobStore, JobTerminalStateUpdateException from mlflow.store.tracking.dbmodels.models import SqlJob from mlflow.utils.time import get_current_time_millis from mlflow.utils.uri import extract_db_type_from_uri @@ -86,6 +86,23 @@ def _with_workspace_field(self, instance): instance.workspace = DEFAULT_WORKSPACE_NAME return instance + @staticmethod + def _clear_job_transient_fields(job: SqlJob) -> None: + # TODO: Move job lifecycle transition policy out of the store once the + # framework owns a shared state-machine/transition layer. The store + # should still apply the resulting field updates atomically. + job.lease_expires_at = None + job.status_message = None + job.progress_payload = None + job.progress_updated_at = None + job.token_hash = None + job.scoped_permissions = None + + @classmethod + def _reset_job_for_pending(cls, job: SqlJob) -> None: + job.result = None + cls._clear_job_transient_fields(job) + def create_job(self, job_name: str, params: str, timeout: float | None = None) -> Job: """ Create a new job with the specified function and parameters. @@ -119,19 +136,30 @@ def create_job(self, job_name: str, params: str, timeout: float | None = None) - session.flush() return job.to_mlflow_entity() - def _update_job(self, job_id: str, new_status: JobStatus, result: str | None = None) -> Job: + def _update_job( + self, + job_id: str, + new_status: JobStatus, + result: str | None = None, + ) -> Job: with self.ManagedSessionMaker() as session: job = self._get_sql_job(session, job_id) - if JobStatus.is_finalized(job.status): - raise MlflowException( - f"The Job {job_id} is already finalized with status: {job.status}, " + if JobStatus.is_finalized(JobStatus.from_int(job.status)): + raise MlflowException.invalid_parameter_value( + "The Job " + f"{job_id} is already finalized with status: {JobStatus.from_int(job.status)}, " "it can't be updated." ) job.status = new_status.to_int() - if result is not None: - job.result = result + if new_status == JobStatus.PENDING: + self._reset_job_for_pending(job) + else: + if result is not None: + job.result = result + if JobStatus.is_finalized(new_status): + self._clear_job_transient_fields(job) job.last_update_time = get_current_time_millis() return job.to_mlflow_entity() @@ -228,12 +256,22 @@ def retry_or_fail_job(self, job_id: str, error: str) -> int | None: with self.ManagedSessionMaker() as session: job = self._get_sql_job(session, job_id) + if JobStatus.is_finalized(JobStatus.from_int(job.status)): + raise MlflowException.invalid_parameter_value( + "The Job " + f"{job_id} is already finalized with status: {JobStatus.from_int(job.status)}, " + "it can't be updated." + ) + if job.retry_count >= max_retries: job.status = JobStatus.FAILED.to_int() job.result = error + self._clear_job_transient_fields(job) + job.last_update_time = get_current_time_millis() return None job.retry_count += 1 job.status = JobStatus.PENDING.to_int() + self._reset_job_for_pending(job) job.last_update_time = get_current_time_millis() return job.retry_count @@ -426,6 +464,9 @@ def update_status_details(self, job_id: str, status_details: dict[str, Any]) -> with self.ManagedSessionMaker() as session: job = self._get_sql_job(session, job_id) + if JobStatus.is_finalized(JobStatus.from_int(job.status)): + raise JobTerminalStateUpdateException(job_id, JobStatus.from_int(job.status)) + # Merge new status details with existing current_details = job.status_details or {} current_details.update(status_details) diff --git a/mlflow/store/tracking/dbmodels/models.py b/mlflow/store/tracking/dbmodels/models.py index bd04e9392f920..9da18006e0f93 100644 --- a/mlflow/store/tracking/dbmodels/models.py +++ b/mlflow/store/tracking/dbmodels/models.py @@ -22,7 +22,7 @@ UnicodeText, UniqueConstraint, ) -from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.ext.mutable import MutableDict, MutableList from sqlalchemy.inspection import inspect from sqlalchemy.orm import backref, relationship @@ -111,8 +111,9 @@ ] -# Create MutableJSON type for tracking mutations in JSON columns +# Create mutable JSON types for tracking mutations in JSON columns. MutableJSON = MutableDict.as_mutable(JSON) +MutableJSONArray = MutableList.as_mutable(JSON) class SqlExperiment(Base): @@ -2333,6 +2334,41 @@ class SqlJob(Base): Last Update time of experiment: `BigInteger`. """ + executor_backend = Column(String(255), nullable=True) + """ + Persisted executor backend name for retry, cancellation, and recovery: `String` (limit 255). + """ + + lease_expires_at = Column(BigInteger(), nullable=True) + """ + Lease expiration timestamp in milliseconds since the UNIX epoch: `BigInteger`. + """ + + status_message = Column(Text, nullable=True) + """ + Latest best-effort in-flight status message: `Text`. + """ + + progress_payload = Column(MutableJSON, nullable=True) + """ + Latest best-effort structured progress payload: `JSON`. + """ + + progress_updated_at = Column(BigInteger(), nullable=True) + """ + Progress update timestamp in milliseconds since the UNIX epoch: `BigInteger`. + """ + + token_hash = Column(String(64), nullable=True) + """ + SHA-256 hex digest of the remote execution token: `String` (limit 64). + """ + + scoped_permissions = Column(MutableJSONArray, nullable=True) + """ + Persisted remote-execution scoped permissions list: `JSON`. + """ + status_details = Column(MutableJSON, nullable=True) """ Job status details: `JSON`. @@ -2348,6 +2384,11 @@ class SqlJob(Base): "status", "creation_time", ), + Index( + "index_jobs_status_lease_expires_at", + "status", + "lease_expires_at", + ), ) def __repr__(self): @@ -2363,21 +2404,98 @@ def to_mlflow_entity(self): from mlflow.entities._job import Job from mlflow.entities._job_status import JobStatus + status = JobStatus.from_int(self.status) + error_message = ( + self.result + if status in {JobStatus.FAILED, JobStatus.TIMEOUT} and isinstance(self.result, str) + else None + ) + return Job( job_id=self.id, creation_time=self.creation_time, job_name=self.job_name, params=self.params, timeout=self.timeout, - status=JobStatus.from_int(self.status), + status=status, result=self.result, retry_count=self.retry_count, last_update_time=self.last_update_time, workspace=self.workspace, + error_message=error_message, + executor_backend=self.executor_backend, + lease_expires_at=self.lease_expires_at, + status_message=self.status_message, + progress_payload=self.progress_payload, + progress_updated_at=self.progress_updated_at, + token_hash=self.token_hash, + scoped_permissions=self.scoped_permissions, status_details=self.status_details, ) +class SqlJobLock(Base): + """ + DB model for framework-managed exclusive job locks. + + These are recorded in the ``job_locks`` table. + """ + + __tablename__ = "job_locks" + + lock_key = Column(String(255), nullable=False) + """ + Framework-computed exclusive lock key: `String` (limit 255). Primary key. + """ + + job_id = Column(String(36), ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False) + """ + Holding job ID: `String` (limit 36). Foreign key into ``jobs`` table. + """ + + acquired_at = Column(BigInteger(), default=get_current_time_millis, nullable=False) + """ + Lock acquisition timestamp in milliseconds since the UNIX epoch: `BigInteger`. + """ + + __table_args__ = ( + PrimaryKeyConstraint("lock_key", name="job_locks_pk"), + Index("index_job_locks_job_id", "job_id"), + ) + + def __repr__(self): + return f"" + + +class SqlSchedulerLease(Base): + """ + DB model for framework-managed scheduler leases. These are recorded in the + ``scheduler_leases`` table. + """ + + __tablename__ = "scheduler_leases" + + lease_key = Column(String(255), nullable=False) + """ + Scheduler lease key: `String` (limit 255). Primary key. + """ + + acquired_at = Column(BigInteger(), default=get_current_time_millis, nullable=False) + """ + Lease acquisition / renewal timestamp in milliseconds since the UNIX epoch: `BigInteger`. + """ + + ttl_seconds = Column(Integer, nullable=False) + """ + Lease time-to-live in seconds: `Integer`. + """ + + __table_args__ = (PrimaryKeyConstraint("lease_key", name="scheduler_leases_pk"),) + + def __repr__(self): + return f"" + + class SqlGatewaySecret(Base): """ DB model for secrets. These are recorded in the ``secrets`` table. diff --git a/tests/db/schemas/mssql.sql b/tests/db/schemas/mssql.sql index bb4c2c4966879..300a8d15a5fc3 100644 --- a/tests/db/schemas/mssql.sql +++ b/tests/db/schemas/mssql.sql @@ -92,6 +92,13 @@ CREATE TABLE jobs ( last_update_time BIGINT NOT NULL, workspace VARCHAR(63) COLLATE "SQL_Latin1_General_CP1_CI_AS" DEFAULT ('default') NOT NULL, status_details NVARCHAR COLLATE "SQL_Latin1_General_CP1_CI_AS", + executor_backend VARCHAR(255) COLLATE "SQL_Latin1_General_CP1_CI_AS", + lease_expires_at BIGINT, + status_message VARCHAR COLLATE "SQL_Latin1_General_CP1_CI_AS", + progress_payload NVARCHAR COLLATE "SQL_Latin1_General_CP1_CI_AS", + progress_updated_at BIGINT, + token_hash VARCHAR(64) COLLATE "SQL_Latin1_General_CP1_CI_AS", + scoped_permissions NVARCHAR COLLATE "SQL_Latin1_General_CP1_CI_AS", CONSTRAINT jobs_pk PRIMARY KEY (id) ) @@ -106,6 +113,14 @@ CREATE TABLE registered_models ( ) +CREATE TABLE scheduler_leases ( + lease_key VARCHAR(255) COLLATE "SQL_Latin1_General_CP1_CI_AS" NOT NULL, + acquired_at BIGINT NOT NULL, + ttl_seconds INTEGER NOT NULL, + CONSTRAINT scheduler_leases_pk PRIMARY KEY (lease_key) +) + + CREATE TABLE secrets ( secret_id VARCHAR(36) COLLATE "SQL_Latin1_General_CP1_CI_AS" NOT NULL, secret_name VARCHAR(255) COLLATE "SQL_Latin1_General_CP1_CI_AS" NOT NULL, @@ -219,6 +234,15 @@ CREATE TABLE experiment_tags ( ) +CREATE TABLE job_locks ( + lock_key VARCHAR(255) COLLATE "SQL_Latin1_General_CP1_CI_AS" NOT NULL, + job_id VARCHAR(36) COLLATE "SQL_Latin1_General_CP1_CI_AS" NOT NULL, + acquired_at BIGINT NOT NULL, + CONSTRAINT job_locks_pk PRIMARY KEY (lock_key), + CONSTRAINT fk_job_locks_job_id FOREIGN KEY(job_id) REFERENCES jobs (id) ON DELETE CASCADE +) + + CREATE TABLE logged_models ( model_id VARCHAR(36) COLLATE "SQL_Latin1_General_CP1_CI_AS" NOT NULL, experiment_id INTEGER NOT NULL, diff --git a/tests/db/schemas/mysql.sql b/tests/db/schemas/mysql.sql index 6223d752ad613..716a38a2404b3 100644 --- a/tests/db/schemas/mysql.sql +++ b/tests/db/schemas/mysql.sql @@ -93,6 +93,13 @@ CREATE TABLE jobs ( last_update_time BIGINT NOT NULL, workspace VARCHAR(63) DEFAULT 'default' NOT NULL, status_details JSON, + executor_backend VARCHAR(255), + lease_expires_at BIGINT, + status_message TEXT, + progress_payload JSON, + progress_updated_at BIGINT, + token_hash VARCHAR(64), + scoped_permissions JSON, PRIMARY KEY (id) ) @@ -107,6 +114,14 @@ CREATE TABLE registered_models ( ) +CREATE TABLE scheduler_leases ( + lease_key VARCHAR(255) NOT NULL, + acquired_at BIGINT NOT NULL, + ttl_seconds INTEGER NOT NULL, + CONSTRAINT scheduler_leases_pk PRIMARY KEY (lease_key) +) + + CREATE TABLE secrets ( secret_id VARCHAR(36) NOT NULL, secret_name VARCHAR(255) NOT NULL, @@ -220,6 +235,15 @@ CREATE TABLE experiment_tags ( ) +CREATE TABLE job_locks ( + lock_key VARCHAR(255) NOT NULL, + job_id VARCHAR(36) NOT NULL, + acquired_at BIGINT NOT NULL, + PRIMARY KEY (lock_key), + CONSTRAINT fk_job_locks_job_id FOREIGN KEY(job_id) REFERENCES jobs (id) ON DELETE CASCADE +) + + CREATE TABLE logged_models ( model_id VARCHAR(36) NOT NULL, experiment_id INTEGER NOT NULL, diff --git a/tests/db/schemas/postgresql.sql b/tests/db/schemas/postgresql.sql index d54ec884436a0..1ef41013ecc44 100644 --- a/tests/db/schemas/postgresql.sql +++ b/tests/db/schemas/postgresql.sql @@ -93,6 +93,13 @@ CREATE TABLE jobs ( last_update_time BIGINT NOT NULL, workspace VARCHAR(63) DEFAULT 'default'::character varying NOT NULL, status_details JSON, + executor_backend VARCHAR(255), + lease_expires_at BIGINT, + status_message TEXT, + progress_payload JSON, + progress_updated_at BIGINT, + token_hash VARCHAR(64), + scoped_permissions JSON, CONSTRAINT jobs_pk PRIMARY KEY (id) ) @@ -107,6 +114,14 @@ CREATE TABLE registered_models ( ) +CREATE TABLE scheduler_leases ( + lease_key VARCHAR(255) NOT NULL, + acquired_at BIGINT NOT NULL, + ttl_seconds INTEGER NOT NULL, + CONSTRAINT scheduler_leases_pk PRIMARY KEY (lease_key) +) + + CREATE TABLE secrets ( secret_id VARCHAR(36) NOT NULL, secret_name VARCHAR(255) NOT NULL, @@ -221,6 +236,15 @@ CREATE TABLE experiment_tags ( ) +CREATE TABLE job_locks ( + lock_key VARCHAR(255) NOT NULL, + job_id VARCHAR(36) NOT NULL, + acquired_at BIGINT NOT NULL, + CONSTRAINT job_locks_pk PRIMARY KEY (lock_key), + CONSTRAINT fk_job_locks_job_id FOREIGN KEY(job_id) REFERENCES jobs (id) ON DELETE CASCADE +) + + CREATE TABLE logged_models ( model_id VARCHAR(36) NOT NULL, experiment_id INTEGER NOT NULL, diff --git a/tests/db/schemas/sqlite.sql b/tests/db/schemas/sqlite.sql index c21762216f303..01fff99f3e871 100644 --- a/tests/db/schemas/sqlite.sql +++ b/tests/db/schemas/sqlite.sql @@ -93,6 +93,13 @@ CREATE TABLE jobs ( last_update_time BIGINT NOT NULL, workspace VARCHAR(63) DEFAULT 'default' NOT NULL, status_details JSON, + executor_backend VARCHAR(255), + lease_expires_at BIGINT, + status_message TEXT, + progress_payload JSON, + progress_updated_at BIGINT, + token_hash VARCHAR(64), + scoped_permissions JSON, CONSTRAINT jobs_pk PRIMARY KEY (id) ) @@ -107,6 +114,14 @@ CREATE TABLE registered_models ( ) +CREATE TABLE scheduler_leases ( + lease_key VARCHAR(255) NOT NULL, + acquired_at BIGINT NOT NULL, + ttl_seconds INTEGER NOT NULL, + CONSTRAINT scheduler_leases_pk PRIMARY KEY (lease_key) +) + + CREATE TABLE secrets ( secret_id VARCHAR(36) NOT NULL, secret_name VARCHAR(255) NOT NULL, @@ -221,6 +236,15 @@ CREATE TABLE experiment_tags ( ) +CREATE TABLE job_locks ( + lock_key VARCHAR(255) NOT NULL, + job_id VARCHAR(36) NOT NULL, + acquired_at BIGINT NOT NULL, + CONSTRAINT job_locks_pk PRIMARY KEY (lock_key), + CONSTRAINT fk_job_locks_job_id FOREIGN KEY(job_id) REFERENCES jobs (id) ON DELETE CASCADE +) + + CREATE TABLE logged_models ( model_id VARCHAR(36) NOT NULL, experiment_id INTEGER NOT NULL, diff --git a/tests/db/test_job_persistence_migration.py b/tests/db/test_job_persistence_migration.py new file mode 100644 index 0000000000000..9de508ff9db3e --- /dev/null +++ b/tests/db/test_job_persistence_migration.py @@ -0,0 +1,136 @@ +from pathlib import Path + +import sqlalchemy as sa +from alembic import command + +from mlflow.entities._job_status import JobStatus +from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES +from mlflow.store.db.utils import _get_alembic_config +from mlflow.store.jobs.sqlalchemy_workspace_store import WorkspaceAwareSqlAlchemyJobStore +from mlflow.store.tracking.dbmodels.initial_models import Base as InitialBase +from mlflow.utils.workspace_context import WorkspaceContext + +REVISION = "dc11669786a5" +PREVIOUS_REVISION = "ae8bbe7743c9" + +_LEGACY_JOBS = sa.table( + "jobs", + sa.column("id"), + sa.column("creation_time"), + sa.column("job_name"), + sa.column("params"), + sa.column("workspace"), + sa.column("timeout"), + sa.column("status"), + sa.column("result"), + sa.column("retry_count"), + sa.column("last_update_time"), + sa.column("status_details"), +) + + +def _prepare_database(tmp_path: Path): + db_path = tmp_path / "job_persistence_migration.sqlite" + db_url = f"sqlite:///{db_path}" + engine = sa.create_engine(db_url) + InitialBase.metadata.create_all(engine) + config = _get_alembic_config(db_url) + command.upgrade(config, PREVIOUS_REVISION) + return engine, config, db_url + + +def test_job_persistence_migration_adds_schema_and_cancels_legacy_non_terminal_jobs( + tmp_path: Path, +): + engine, config, _ = _prepare_database(tmp_path) + + with engine.begin() as conn: + conn.execute( + sa.insert(_LEGACY_JOBS), + [ + { + "id": "job-pending", + "creation_time": 1000, + "job_name": "pending_job", + "params": "{}", + "workspace": "default", + "timeout": None, + "status": JobStatus.PENDING.to_int(), + "result": None, + "retry_count": 0, + "last_update_time": 1000, + "status_details": None, + }, + { + "id": "job-running", + "creation_time": 2000, + "job_name": "running_job", + "params": "{}", + "workspace": "default", + "timeout": None, + "status": JobStatus.RUNNING.to_int(), + "result": None, + "retry_count": 0, + "last_update_time": 2000, + "status_details": None, + }, + { + "id": "job-succeeded", + "creation_time": 3000, + "job_name": "succeeded_job", + "params": "{}", + "workspace": "default", + "timeout": None, + "status": JobStatus.SUCCEEDED.to_int(), + "result": "ok", + "retry_count": 0, + "last_update_time": 3000, + "status_details": None, + }, + ], + ) + + command.upgrade(config, REVISION) + + inspector = sa.inspect(engine) + assert "job_locks" in inspector.get_table_names() + assert "scheduler_leases" in inspector.get_table_names() + + job_columns = {column["name"] for column in inspector.get_columns("jobs")} + assert { + "executor_backend", + "lease_expires_at", + "status_message", + "progress_payload", + "progress_updated_at", + "token_hash", + "scoped_permissions", + }.issubset(job_columns) + + jobs = sa.table("jobs", sa.column("id"), sa.column("status")) + with engine.connect() as conn: + migrated_statuses = { + row.id: row.status for row in conn.execute(sa.select(jobs.c.id, jobs.c.status)) + } + + assert migrated_statuses["job-pending"] == JobStatus.CANCELED.to_int() + assert migrated_statuses["job-running"] == JobStatus.CANCELED.to_int() + assert migrated_statuses["job-succeeded"] == JobStatus.SUCCEEDED.to_int() + + +def test_workspace_aware_job_store_works_after_job_persistence_migration( + monkeypatch, + tmp_path: Path, +): + monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true") + _, config, db_url = _prepare_database(tmp_path) + command.upgrade(config, REVISION) + + store = WorkspaceAwareSqlAlchemyJobStore(db_url) + + with WorkspaceContext("team-a"): + job = store.create_job("test_job", '{"value": 1}') + fetched = store.get_job(job.job_id) + + assert fetched.workspace == "team-a" + assert fetched.status == JobStatus.PENDING diff --git a/tests/entities/test_job_status.py b/tests/entities/test_job_status.py index 1d90348b5682f..f1f46e29df982 100644 --- a/tests/entities/test_job_status.py +++ b/tests/entities/test_job_status.py @@ -9,6 +9,7 @@ [ (JobStatus.PENDING, ProtoJobStatus.JOB_STATUS_PENDING), (JobStatus.RUNNING, ProtoJobStatus.JOB_STATUS_IN_PROGRESS), + (JobStatus.NEEDS_RECOVERY, ProtoJobStatus.JOB_STATUS_NEEDS_RECOVERY), (JobStatus.SUCCEEDED, ProtoJobStatus.JOB_STATUS_COMPLETED), (JobStatus.FAILED, ProtoJobStatus.JOB_STATUS_FAILED), (JobStatus.TIMEOUT, ProtoJobStatus.JOB_STATUS_FAILED), @@ -17,3 +18,24 @@ ) def test_job_status_to_proto(status, expected_proto): assert status.to_proto() == expected_proto + + +@pytest.mark.parametrize( + ("status", "expected_int"), + [ + (JobStatus.PENDING, 0), + (JobStatus.RUNNING, 1), + (JobStatus.SUCCEEDED, 2), + (JobStatus.FAILED, 3), + (JobStatus.TIMEOUT, 4), + (JobStatus.CANCELED, 5), + (JobStatus.NEEDS_RECOVERY, 6), + ], +) +def test_job_status_stable_int_mapping(status, expected_int): + assert status.to_int() == expected_int + assert JobStatus.from_int(expected_int) == status + + +def test_needs_recovery_is_not_finalized(): + assert JobStatus.is_finalized(JobStatus.NEEDS_RECOVERY) is False diff --git a/tests/resources/db/latest_schema.sql b/tests/resources/db/latest_schema.sql index c21762216f303..01fff99f3e871 100644 --- a/tests/resources/db/latest_schema.sql +++ b/tests/resources/db/latest_schema.sql @@ -93,6 +93,13 @@ CREATE TABLE jobs ( last_update_time BIGINT NOT NULL, workspace VARCHAR(63) DEFAULT 'default' NOT NULL, status_details JSON, + executor_backend VARCHAR(255), + lease_expires_at BIGINT, + status_message TEXT, + progress_payload JSON, + progress_updated_at BIGINT, + token_hash VARCHAR(64), + scoped_permissions JSON, CONSTRAINT jobs_pk PRIMARY KEY (id) ) @@ -107,6 +114,14 @@ CREATE TABLE registered_models ( ) +CREATE TABLE scheduler_leases ( + lease_key VARCHAR(255) NOT NULL, + acquired_at BIGINT NOT NULL, + ttl_seconds INTEGER NOT NULL, + CONSTRAINT scheduler_leases_pk PRIMARY KEY (lease_key) +) + + CREATE TABLE secrets ( secret_id VARCHAR(36) NOT NULL, secret_name VARCHAR(255) NOT NULL, @@ -221,6 +236,15 @@ CREATE TABLE experiment_tags ( ) +CREATE TABLE job_locks ( + lock_key VARCHAR(255) NOT NULL, + job_id VARCHAR(36) NOT NULL, + acquired_at BIGINT NOT NULL, + CONSTRAINT job_locks_pk PRIMARY KEY (lock_key), + CONSTRAINT fk_job_locks_job_id FOREIGN KEY(job_id) REFERENCES jobs (id) ON DELETE CASCADE +) + + CREATE TABLE logged_models ( model_id VARCHAR(36) NOT NULL, experiment_id INTEGER NOT NULL, diff --git a/tests/server/jobs/test_endpoint.py b/tests/server/jobs/test_endpoint.py index 3cbea699771d0..72b15426997d3 100644 --- a/tests/server/jobs/test_endpoint.py +++ b/tests/server/jobs/test_endpoint.py @@ -174,8 +174,12 @@ def test_job_submit(client: Client): "timeout": None, "status": "SUCCEEDED", "result": {"a": 7, "b": 12}, + "error_message": None, "retry_count": 0, "status_details": None, + "status_message": None, + "progress_payload": None, + "progress_updated_at": None, } @@ -205,8 +209,12 @@ def test_job_cancel(client: Client): "timeout": None, "status": "CANCELED", "result": None, + "error_message": None, "retry_count": 0, "status_details": None, + "status_message": None, + "progress_payload": None, + "progress_updated_at": None, } @@ -334,10 +342,11 @@ def extract_job_ids(jobs: list[dict[str, Any]]) -> list[str]: }, ) assert response.status_code == 422 - assert ( - response.json()["detail"][0]["msg"] - == "Input should be 'PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED', 'TIMEOUT' or 'CANCELED'" + expected_message = ( + "Input should be 'PENDING', 'RUNNING', 'NEEDS_RECOVERY', " + "'SUCCEEDED', 'FAILED', 'TIMEOUT' or 'CANCELED'" ) + assert response.json()["detail"][0]["msg"] == expected_message def test_job_status_details_in_api_response(client: Client): @@ -353,3 +362,7 @@ def test_job_status_details_in_api_response(client: Client): assert job_json["status_details"].get("stage") == "done" assert job_json["status"] == "SUCCEEDED" assert job_json["result"] == "completed" + assert job_json["error_message"] is None + assert job_json["status_message"] is None + assert job_json["progress_payload"] is None + assert job_json["progress_updated_at"] is None diff --git a/tests/server/jobs/test_jobs.py b/tests/server/jobs/test_jobs.py index 5c33c2610d6fb..b89d0eabb9bd2 100644 --- a/tests/server/jobs/test_jobs.py +++ b/tests/server/jobs/test_jobs.py @@ -9,6 +9,7 @@ import pytest import mlflow.store.jobs.sqlalchemy_store +from mlflow.entities._job import JobProgress, JobScopedPermission from mlflow.entities._job_status import JobStatus from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES, MLFLOW_WORKSPACE from mlflow.exceptions import MlflowException @@ -30,8 +31,10 @@ _enqueue_unfinished_jobs, _exec_job, ) +from mlflow.store.jobs.abstract_store import JobTerminalStateUpdateException from mlflow.store.jobs.sqlalchemy_store import SqlAlchemyJobStore from mlflow.store.jobs.sqlalchemy_workspace_store import WorkspaceAwareSqlAlchemyJobStore +from mlflow.store.tracking.dbmodels.models import SqlJob, SqlJobLock from mlflow.utils.workspace_context import WorkspaceContext from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME @@ -136,6 +139,7 @@ def test_error_job(monkeypatch, tmp_path): assert job.job_name == "err_fun" assert job.params == '{"data": null}' assert job.result.startswith("RuntimeError()") + assert job.error_message.startswith("RuntimeError()") assert job.status == JobStatus.FAILED assert job.retry_count == 0 @@ -360,6 +364,7 @@ def test_job_retry_on_transient_error(monkeypatch, tmp_path): job1 = store.get_job(job1_id) assert job1.status == JobStatus.FAILED assert job1.result == "RuntimeError('test transient error.')" + assert job1.error_message == "RuntimeError('test transient error.')" assert job1.retry_count == 2 # Test 2: Job that fails once then succeeds should succeed with retry_count=1 @@ -372,6 +377,7 @@ def test_job_retry_on_transient_error(monkeypatch, tmp_path): job2 = store.get_job(job2_id) assert job2.status == JobStatus.SUCCEEDED assert job2.result == "100" + assert job2.error_message is None assert job2.retry_count == 1 # Test 3: Same as test 2 but with custom transient_error_classes @@ -384,9 +390,124 @@ def test_job_retry_on_transient_error(monkeypatch, tmp_path): job3 = store.get_job(job3_id) assert job3.status == JobStatus.SUCCEEDED assert job3.result == "100" + assert job3.error_message is None assert job3.retry_count == 1 +def test_retry_or_fail_job_clears_transient_fields_on_exhaustion(monkeypatch, tmp_path: Path): + monkeypatch.setenv("MLFLOW_SERVER_JOB_TRANSIENT_ERROR_MAX_RETRIES", "0") + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("test_job", "{}") + store.start_job(job.job_id) + + with store.ManagedSessionMaker() as session: + session.query(SqlJob).filter(SqlJob.id == job.job_id).update({ + SqlJob.lease_expires_at: int(time.time() * 1000), + SqlJob.status_message: "running", + SqlJob.progress_payload: {"completed": 1, "total": 2}, + SqlJob.progress_updated_at: int(time.time() * 1000), + SqlJob.token_hash: "abc123", + SqlJob.scoped_permissions: [ + { + "resource_type": "experiment", + "resource_identifier": "1", + "workspace": None, + "permission": "EDIT", + } + ], + }) + + retry_count = store.retry_or_fail_job(job.job_id, "retry exhausted") + assert retry_count is None + + updated_job = store.get_job(job.job_id) + assert updated_job.status == JobStatus.FAILED + assert updated_job.result == "retry exhausted" + assert updated_job.error_message == "retry exhausted" + assert updated_job.status_message is None + assert updated_job.progress_payload is None + assert updated_job.progress_updated_at is None + + with store.ManagedSessionMaker() as session: + sql_job = session.query(SqlJob).filter(SqlJob.id == job.job_id).one() + assert sql_job.lease_expires_at is None + assert sql_job.token_hash is None + assert sql_job.scoped_permissions is None + + +def test_job_progress_payload_hydrates_to_dataclass(tmp_path: Path): + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("test_job", "{}") + with store.ManagedSessionMaker() as session: + session.query(SqlJob).filter(SqlJob.id == job.job_id).update({ + SqlJob.progress_payload: { + "phase": "scoring", + "completed": 2, + "total": 5, + "unit": "traces", + } + }) + + updated_job = store.get_job(job.job_id) + assert updated_job.progress_payload == JobProgress( + phase="scoring", + completed=2, + total=5, + unit="traces", + ) + + +def test_job_progress_round_trips_to_proto(): + progress = JobProgress(phase="scoring", completed=2, total=5, unit="traces") + + proto = progress.to_proto() + assert JobProgress.from_proto(proto) == progress + + +def test_job_scoped_permissions_payload_hydrates_to_dataclass(tmp_path: Path): + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("test_job", "{}") + with store.ManagedSessionMaker() as session: + session.query(SqlJob).filter(SqlJob.id == job.job_id).update({ + SqlJob.scoped_permissions: [ + { + "resource_type": "experiment", + "resource_identifier": "2", + "workspace": "team-a", + "permission": "EDIT", + }, + { + "resource_type": "gateway_endpoint", + "resource_identifier": "endpoint-1", + "workspace": "team-a", + "permission": "USE", + }, + ] + }) + + updated_job = store.get_job(job.job_id) + assert updated_job.scoped_permissions == [ + JobScopedPermission( + resource_type="experiment", + resource_identifier="2", + workspace="team-a", + permission="EDIT", + ), + JobScopedPermission( + resource_type="gateway_endpoint", + resource_identifier="endpoint-1", + workspace="team-a", + permission="USE", + ), + ] + + # `submit_job` API is designed to be called inside MLflow server handler, # MLflow server handler might be executed in multiple MLflow server workers # so that we need a test to cover the case that executes `submit_job` in @@ -457,10 +578,22 @@ def test_job_timeout(monkeypatch, tmp_path): assert job.job_name == "sleep_fun" assert job.timeout == 3.0 assert job.result is None + assert job.error_message is None assert job.status == JobStatus.TIMEOUT assert job.retry_count == 0 +def test_update_status_details_raises_specific_exception_for_terminal_job(tmp_path: Path): + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("test_job", "{}") + store.fail_job(job.job_id, "boom") + + with pytest.raises(JobTerminalStateUpdateException, match="already finalized"): + store.update_status_details(job.job_id, {"stage": "late-heartbeat"}) + + def test_list_job_pagination(monkeypatch, tmp_path): monkeypatch.setattr(mlflow.store.jobs.sqlalchemy_store, "_LIST_JOB_PAGE_SIZE", 3) with _setup_job_runner( @@ -673,12 +806,23 @@ def test_delete_jobs_only_deletes_finalized(tmp_path: Path): store.mark_job_timed_out(timeout_job.job_id) timeout_job = store.get_job(timeout_job.job_id) assert timeout_job.status == JobStatus.TIMEOUT + assert timeout_job.error_message is None canceled_job = store.create_job("canceled_job", "{}") store.cancel_job(canceled_job.job_id) canceled_job = store.get_job(canceled_job.job_id) assert canceled_job.status == JobStatus.CANCELED + needs_recovery_job = store.create_job("needs_recovery_job", "{}") + store.start_job(needs_recovery_job.job_id) + with store.ManagedSessionMaker() as session: + session.query(SqlJob).filter(SqlJob.id == needs_recovery_job.job_id).update({ + SqlJob.status: JobStatus.NEEDS_RECOVERY.to_int(), + SqlJob.last_update_time: int(time.time() * 1000), + }) + needs_recovery_job = store.get_job(needs_recovery_job.job_id) + assert needs_recovery_job.status == JobStatus.NEEDS_RECOVERY + deleted_ids = store.delete_jobs() # Should only delete finalized jobs @@ -691,6 +835,7 @@ def test_delete_jobs_only_deletes_finalized(tmp_path: Path): # Non-finalized jobs should still exist assert store.get_job(pending_job.job_id).status == JobStatus.PENDING assert store.get_job(running_job.job_id).status == JobStatus.RUNNING + assert store.get_job(needs_recovery_job.job_id).status == JobStatus.NEEDS_RECOVERY # Finalized jobs should be deleted with pytest.raises(MlflowException, match=r"Job .+ not found"): @@ -916,6 +1061,34 @@ def test_reenqueued_jobs_respect_workspace_disabled(monkeypatch, db_uri): assert workspace is None +def test_reenqueued_needs_recovery_jobs_are_reset_and_resubmitted(monkeypatch, db_uri): + job_store = SqlAlchemyJobStore(db_uri) + job = job_store.create_job("basic_job_fun", '{"x": 1, "y": 2}', None) + job_store.start_job(job.job_id) + + with job_store.ManagedSessionMaker() as session: + session.query(SqlJob).filter(SqlJob.id == job.job_id).update({ + SqlJob.status: JobStatus.NEEDS_RECOVERY.to_int() + }) + + with ( + mock.patch("mlflow.server.handlers._get_job_store", return_value=job_store), + mock.patch( + "mlflow.server.jobs.utils.get_job_fn_fullname", + return_value="tests.server.jobs.test_jobs.basic_job_fun", + ), + mock.patch("mlflow.server.jobs.utils._load_function", return_value=basic_job_fun), + mock.patch("mlflow.server.jobs.utils._get_or_init_huey_instance") as mock_huey, + ): + mock_submit = mock.Mock() + mock_huey.return_value.submit_task = mock_submit + + _enqueue_unfinished_jobs(int(time.time() * 1000)) + + mock_submit.assert_called_once() + assert job_store.get_job(job.job_id).status == JobStatus.PENDING + + def test_update_status_details(tmp_path: Path): backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" store = SqlAlchemyJobStore(backend_store_uri) @@ -932,6 +1105,55 @@ def test_update_status_details(tmp_path: Path): assert updated_job.status_details == {"stage": "processing", "progress": "50%"} +def test_update_status_details_rejects_finalized_job(tmp_path: Path): + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("test_job", '{"param": "value"}') + store.start_job(job.job_id) + store.finish_job(job.job_id, "done") + + with pytest.raises(MlflowException, match="already finalized"): + store.update_status_details(job.job_id, {"stage": "should-fail"}) + + +def test_delete_jobs_cascades_job_locks(tmp_path: Path): + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("finished_job", "{}") + store.start_job(job.job_id) + store.finish_job(job.job_id, "result") + + with store.ManagedSessionMaker() as session: + session.add( + SqlJobLock( + lock_key="finished_job:1234", + job_id=job.job_id, + acquired_at=int(time.time() * 1000), + ) + ) + + deleted_ids = store.delete_jobs(job_ids=[job.job_id]) + assert deleted_ids == [job.job_id] + + with store.ManagedSessionMaker() as session: + remaining_locks = session.query(SqlJobLock).filter(SqlJobLock.job_id == job.job_id).count() + assert remaining_locks == 0 + + +def test_finalized_jobs_cannot_be_retransitioned(tmp_path: Path): + backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" + store = SqlAlchemyJobStore(backend_store_uri) + + job = store.create_job("finished_job", "{}") + store.start_job(job.job_id) + store.finish_job(job.job_id, "result") + + with pytest.raises(MlflowException, match="already finalized"): + store.cancel_job(job.job_id) + + def test_update_status_details_merges_with_existing(tmp_path: Path): backend_store_uri = f"sqlite:///{tmp_path / 'test.db'}" store = SqlAlchemyJobStore(backend_store_uri) diff --git a/tests/server/jobs/test_jobs_workspace.py b/tests/server/jobs/test_jobs_workspace.py index 4dde0095c3019..646b87de380d2 100644 --- a/tests/server/jobs/test_jobs_workspace.py +++ b/tests/server/jobs/test_jobs_workspace.py @@ -126,6 +126,22 @@ def test_create_job_initializes_metadata_as_none(tmp_path: Path): job = store.create_job("test_job", '{"param": "value"}') assert job.status_details is None + assert job.error_message is None + assert job.executor_backend is None + assert job.lease_expires_at is None + assert job.status_message is None + assert job.progress_payload is None + assert job.progress_updated_at is None + assert job.token_hash is None + assert job.scoped_permissions is None fetched_job = store.get_job(job.job_id) assert fetched_job.status_details is None + assert fetched_job.error_message is None + assert fetched_job.executor_backend is None + assert fetched_job.lease_expires_at is None + assert fetched_job.status_message is None + assert fetched_job.progress_payload is None + assert fetched_job.progress_updated_at is None + assert fetched_job.token_hash is None + assert fetched_job.scoped_permissions is None diff --git a/tests/server/jobs/test_progress.py b/tests/server/jobs/test_progress.py index a681d7eeff2d1..b4e1a52b1456c 100644 --- a/tests/server/jobs/test_progress.py +++ b/tests/server/jobs/test_progress.py @@ -1,5 +1,8 @@ from unittest import mock +import pytest + +from mlflow.exceptions import MlflowException from mlflow.server.jobs.progress import ( JobTracker, NoOpTracker, @@ -7,6 +10,7 @@ _set_job_tracker, update_status_details, ) +from mlflow.store.jobs.abstract_store import JobTerminalStateUpdateException def test_job_tracker_writes_to_database(): @@ -153,3 +157,36 @@ def test_update_status_details_with_additional_fields(): ) _set_job_tracker(None) + + +def test_job_tracker_ignores_already_finalized_error(): + job_id = "test-job-finalized" + tracker = JobTracker(job_id) + + with mock.patch("mlflow.server.handlers._get_job_store") as mock_get_store: + mock_store = mock.Mock() + mock_store.update_status_details.side_effect = JobTerminalStateUpdateException( + job_id, "SUCCEEDED" + ) + mock_get_store.return_value = mock_store + + tracker.update({"stage": "late-heartbeat"}) + + mock_store.update_status_details.assert_called_once_with( + job_id, {"stage": "late-heartbeat"} + ) + + +def test_job_tracker_reraises_other_mlflow_errors(): + job_id = "test-job-error" + tracker = JobTracker(job_id) + + with mock.patch("mlflow.server.handlers._get_job_store") as mock_get_store: + mock_store = mock.Mock() + mock_store.update_status_details.side_effect = MlflowException.invalid_parameter_value( + "bad status details" + ) + mock_get_store.return_value = mock_store + + with pytest.raises(MlflowException, match="bad status details"): + tracker.update({"stage": "late-heartbeat"}) diff --git a/tests/server/jobs/test_utils.py b/tests/server/jobs/test_utils.py index 105f88e8953af..ca542ee14002d 100644 --- a/tests/server/jobs/test_utils.py +++ b/tests/server/jobs/test_utils.py @@ -55,9 +55,11 @@ def test_job_status_conversion(): from mlflow.entities._job_status import JobStatus assert JobStatus.from_int(1) == JobStatus.RUNNING + assert JobStatus.from_int(6) == JobStatus.NEEDS_RECOVERY assert JobStatus.from_str("RUNNING") == JobStatus.RUNNING assert JobStatus.RUNNING.to_int() == 1 + assert JobStatus.NEEDS_RECOVERY.to_int() == 6 assert str(JobStatus.RUNNING) == "RUNNING" with pytest.raises( @@ -66,9 +68,9 @@ def test_job_status_conversion(): JobStatus.from_int(-1) with pytest.raises( - MlflowException, match="The value 6 can't be converted to JobStatus enum value." + MlflowException, match="The value 7 can't be converted to JobStatus enum value." ): - JobStatus.from_int(6) + JobStatus.from_int(7) with pytest.raises( MlflowException, match="The string 'ABC' can't be converted to JobStatus enum value." diff --git a/tests/server/test_handlers.py b/tests/server/test_handlers.py index f3a062a1c5095..869c2ddab80f8 100644 --- a/tests/server/test_handlers.py +++ b/tests/server/test_handlers.py @@ -332,6 +332,10 @@ def _create_mock_job( result=None, creation_time=1234567890000, status_details=None, + error_message=None, + status_message=None, + progress_payload=None, + progress_updated_at=None, ): from mlflow.entities._job import Job from mlflow.entities._job_status import JobStatus @@ -354,6 +358,10 @@ def _create_mock_job( retry_count=0, last_update_time=creation_time, status_details=status_details, + error_message=error_message, + status_message=status_message, + progress_payload=progress_payload, + progress_updated_at=progress_updated_at, ) @@ -3847,6 +3855,26 @@ def test_get_prompt_optimization_job_failed_with_error(mock_tracking_store): assert "Optimization failed" in job["state"]["error_message"] +def test_get_prompt_optimization_job_timeout_uses_error_message(mock_tracking_store): + mock_job = _create_mock_job( + status_name="TIMEOUT", + error_message="Job execution timed out.", + ) + + mock_run = _create_mock_run() + mock_tracking_store.get_run.return_value = mock_run + + with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job): + with app.test_client() as c: + response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123") + assert response.status_code == 200 + + data = response.get_json() + job = data["job"] + assert job["state"]["status"] == "JOB_STATUS_FAILED" + assert job["state"]["error_message"] == "Job execution timed out." + + def test_get_prompt_optimization_job_without_run_id(mock_tracking_store): mock_job = _create_mock_job( params={"experiment_id": "exp-123", "prompt_uri": "prompts:/my-prompt/1"} @@ -3898,6 +3926,38 @@ def test_get_prompt_optimization_job_with_progress(mock_tracking_store): assert job["state"]["metadata"]["progress"] == "0.43" +def test_get_prompt_optimization_job_includes_structured_progress_fields(mock_tracking_store): + mock_job = _create_mock_job( + status_name="RUNNING", + params={"experiment_id": "exp-123", "prompt_uri": "prompts:/my-prompt/1"}, + status_message="Scoring traces", + progress_payload={ + "phase": "scoring", + "completed": 42, + "total": 100, + "unit": "traces", + }, + progress_updated_at=1234567894321, + ) + + with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job): + with app.test_client() as c: + response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123") + assert response.status_code == 200 + + data = response.get_json() + state = data["job"]["state"] + assert state["status"] == "JOB_STATUS_IN_PROGRESS" + assert state["status_message"] == "Scoring traces" + assert state["progress_payload"] == { + "phase": "scoring", + "completed": 42, + "total": 100, + "unit": "traces", + } + assert state["progress_updated_at"] == 1234567894321 + + def test_get_prompt_optimization_job_progress_capped_at_one(mock_tracking_store): mock_job = _create_mock_job( status_name="RUNNING", @@ -5034,6 +5094,10 @@ def test_get_job_success(mock_job_store): assert json_response["result"]["issues"] == 3 assert json_response["result"]["total_traces_analyzed"] == 10 assert json_response["status_details"] is None + assert json_response["error_message"] is None + assert json_response["status_message"] is None + assert json_response["progress_payload"] is None + assert json_response["progress_updated_at"] is None def test_get_job_pending(mock_job_store): @@ -5061,6 +5125,73 @@ def test_get_job_pending(mock_job_store): assert json_response["status"] == "PENDING" assert json_response["result"] is None assert json_response["status_details"] is None + assert json_response["error_message"] is None + assert json_response["status_message"] is None + assert json_response["progress_payload"] is None + assert json_response["progress_updated_at"] is None + + +def test_get_job_timeout_without_timeout_message(mock_job_store): + mock_job = JobEntity( + job_id="job-timeout", + creation_time=1234567890000, + job_name="invoke_issue_detection", + params='{"experiment_id": "exp-123"}', + timeout=None, + status=JobStatus.TIMEOUT, + result=None, + retry_count=0, + last_update_time=1234567895000, + status_details=None, + error_message=None, + ) + + with ( + mock.patch("mlflow.server.jobs.get_job", return_value=mock_job), + app.test_client() as c, + ): + resp = c.get("/ajax-api/3.0/mlflow/jobs/job-timeout") + assert resp.status_code == 200 + json_response = resp.get_json() + + assert json_response["status"] == "TIMEOUT" + assert json_response["result"] is None + assert json_response["status_details"] is None + assert json_response["error_message"] is None + assert json_response["status_message"] is None + assert json_response["progress_payload"] is None + assert json_response["progress_updated_at"] is None + + +def test_get_job_needs_recovery(mock_job_store): + mock_job = JobEntity( + job_id="job-needs-recovery", + creation_time=1234567890000, + job_name="invoke_issue_detection", + params='{"experiment_id": "exp-123"}', + timeout=None, + status=JobStatus.NEEDS_RECOVERY, + result=None, + retry_count=0, + last_update_time=1234567895000, + status_details=None, + ) + + with ( + mock.patch("mlflow.server.jobs.get_job", return_value=mock_job), + app.test_client() as c, + ): + resp = c.get("/ajax-api/3.0/mlflow/jobs/job-needs-recovery") + assert resp.status_code == 200 + json_response = resp.get_json() + + assert json_response["status"] == "NEEDS_RECOVERY" + assert json_response["result"] is None + assert json_response["status_details"] is None + assert json_response["error_message"] is None + assert json_response["status_message"] is None + assert json_response["progress_payload"] is None + assert json_response["progress_updated_at"] is None def test_cancel_job_success(mock_job_store):