From 9922cb05f6a0c716a4c802f3b1533b2e180f2f3f Mon Sep 17 00:00:00 2001 From: chakradhar886 Date: Thu, 2 Jul 2026 23:50:03 +0530 Subject: [PATCH 1/8] [ml] Fix jobs partial update across all job types Root bug: ml_client.jobs.get(name) -> mutate -> jobs.create_or_update(job) fails for AutoML, Command, Pipeline, Sweep and Spark. Each job type raises a different serializer or MFE PUT comparator error (QueueSettings._to_rest_object AttributeError for Sweep, InputBindings/output-binding UserError for Pipeline and Spark, registry-scope UserError for Command, 'Input path can't be empty' for AutoML). This change adds two intentionally narrow fixes: 1. A new public jobs.update(name, *, display_name, description, tags, properties) API that routes through the RunHistory PATCH endpoint used by ML Studio's portal for inline edits. The endpoint is job-type agnostic and merges tags/properties instead of replacing them. 2. A temporary shortcut inside create_or_update that detects the 'fetched job with only metadata changes' case (job carries an ARM id, no compute/experiment_name kwargs supplied) and routes it through the same RunHistory PATCH path. Any failure inside the shortcut falls through to the original create/update logic so brand-new job creation is bit-for-bit unchanged. Verified live against AutoML, Command, Pipeline, Sweep and Spark jobs; MFE GET (api-version 2024-10-01) confirms displayName and merged tags persisted server-side. --- .../azure/ai/ml/operations/_job_operations.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py index 386dafa2ca94..abe990239839 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py @@ -723,6 +723,63 @@ def create_or_update( if experiment_name is not None: job.experiment_name = experiment_name + # ---------------- TEMPORARY HOTFIX (metadata-only shortcut) ---------------- + # Root bug: for a Job fetched via jobs.get(name) then mutated and passed to + # create_or_update, the serialize + MFE PUT round-trip fails for AutoML, + # Command, Pipeline, Sweep, Spark (each with a different serializer / + # comparator error). Until MFE + per-jobType serializers are fixed upstream, + # detect the "fetched job with only metadata changes" case and route through + # the RunHistory PATCH endpoint (same call used by ML Studio's portal for + # inline edits, and by the new jobs.update() API added alongside this fix). + # + # Shortcut is taken only when: + # * the incoming job carries an ARM resource id (=> it was fetched, not + # freshly created), AND + # * the caller is not trying to change compute or experiment_name (these + # are not supported by the shortcut and were rejected by MFE anyway). + # Any failure inside the shortcut falls through to the original path so + # brand-new job creation behavior is preserved bit-for-bit. + if ( + getattr(job, "id", None) + and getattr(job, "name", None) + and compute is None + and experiment_name is None + ): + try: + job_object = self._get_job(job.name) + if job_object.properties.job_type == RestJobType.PIPELINE: + job_object = self._get_job_2401(job.name) + if _is_pipeline_child_job(job_object): + raise PipelineChildJobError(job_id=job_object.id) + + from azure.ai.ml._restclient.runhistory.models import CreateRun + + self._runs_operations._operation.add_or_modify_by_experiment_name( + subscription_id=self._operation_scope.subscription_id, + resource_group_name=self._operation_scope.resource_group_name, + workspace_name=self._workspace_name, + experiment_name=job_object.properties.experiment_name, + run_id=job.name, + body=CreateRun( + display_name=getattr(job, "display_name", None), + description=getattr(job, "description", None), + tags=getattr(job, "tags", None), + properties=getattr(job, "properties", None), + ), + ) + + refreshed = self._get_job(job.name) + if refreshed.properties.job_type == RestJobType.PIPELINE: + refreshed = self._get_job_2401(job.name) + return self._resolve_azureml_id(Job._from_rest_object(refreshed)) + except PipelineChildJobError: + raise + except Exception: # noqa: BLE001 - deliberate broad catch; fall through + # Any failure (job not found, transient RunHistory error, etc.) falls + # through to the original code path so we never regress creation. + pass + # ------------------ END TEMPORARY HOTFIX -------------------------------- + if job.compute == LOCAL_COMPUTE_TARGET: job.environment_variables[COMMON_RUNTIME_ENV_VAR] = "true" # type: ignore @@ -896,6 +953,71 @@ def restore(self, name: str) -> None: self._archive_or_restore(name=name, is_archived=False) + @distributed_trace + @monitor_with_telemetry_mixin(ops_logger, "Job.Update", ActivityType.PUBLICAPI) + def update( + self, + name: str, + *, + display_name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + properties: Optional[Dict[str, str]] = None, + ) -> Job: + """Partially updates the display_name, description, tags or properties of an existing job. + + This routes through the RunHistory PATCH endpoint that Azure ML Studio's portal uses + for inline edits, which works uniformly for all job types (AutoML, Command, Pipeline, + Sweep, Spark, Parallel) and merges tags/properties rather than replacing them. + + :param name: The name of the job to update. + :type name: str + :keyword display_name: New display name for the job. If None, leaves the current value unchanged. + :paramtype display_name: Optional[str] + :keyword description: New description for the job. If None, leaves the current value unchanged. + :paramtype description: Optional[str] + :keyword tags: Tags to merge into the job's existing tags. + :paramtype tags: Optional[Dict[str, str]] + :keyword properties: Properties to merge into the job's existing properties. + :paramtype properties: Optional[Dict[str, str]] + :raises ~azure.ai.ml.exceptions.UserErrorException: Raised if no updatable field is supplied. + :raises ~azure.ai.ml.exceptions.PipelineChildJobError: Raised if the target job is a pipeline child job. + :return: The refreshed Job entity. + :rtype: ~azure.ai.ml.entities.Job + """ + if display_name is None and description is None and tags is None and properties is None: + raise UserErrorException( + "jobs.update() requires at least one of " + "display_name, description, tags or properties to be specified." + ) + + job_object = self._get_job(name) + if job_object.properties.job_type == RestJobType.PIPELINE: + job_object = self._get_job_2401(name) + if _is_pipeline_child_job(job_object): + raise PipelineChildJobError(job_id=job_object.id) + + from azure.ai.ml._restclient.runhistory.models import CreateRun + + self._runs_operations._operation.add_or_modify_by_experiment_name( + subscription_id=self._operation_scope.subscription_id, + resource_group_name=self._operation_scope.resource_group_name, + workspace_name=self._workspace_name, + experiment_name=job_object.properties.experiment_name, + run_id=name, + body=CreateRun( + display_name=display_name, + description=description, + tags=tags, + properties=properties, + ), + ) + + refreshed = self._get_job(name) + if refreshed.properties.job_type == RestJobType.PIPELINE: + refreshed = self._get_job_2401(name) + return Job._from_rest_object(refreshed) + @distributed_trace @monitor_with_activity(ops_logger, "Job.Stream", ActivityType.PUBLICAPI) def stream(self, name: str) -> None: From d3c71c4552ab6f27651677ee1ebdaea0bfb85943 Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Sun, 5 Jul 2026 20:03:04 +0530 Subject: [PATCH 2/8] [ml] Address PR review: narrow broad-except; route archive/restore via RunHistory; add CHANGELOG + api.md - Narrow the try/except in the create_or_update shortcut to only guard the RunHistory PATCH; post-PATCH refresh/resolve failures now surface directly instead of silently falling back to the buggy MFE PUT path (addresses Copilot / saanikaguptamicrosoft review). - Route _archive_or_restore through the same RunHistory shortcut so jobs.archive/restore work for Command/Sweep/Spark/Parallel (were previously broken for all non-Pipeline types due to the same MFE PUT round-trip bug). - Add CHANGELOG entry for jobs.update() and the create_or_update fix. - Add jobs.update signature to api.md. --- sdk/ml/azure-ai-ml/CHANGELOG.md | 2 + sdk/ml/azure-ai-ml/api.md | 12 ++++ .../azure/ai/ml/operations/_job_operations.py | 57 ++++++++++++++----- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index bc3df33c6c49..fa871a5a8c05 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -3,8 +3,10 @@ ## 1.35.0 (unreleased) ### Features Added +- Added `MLClient.jobs.update(name, *, display_name=None, description=None, tags=None, properties=None)` to partially update a job's mutable metadata (display name, description, tags, properties) without a full `create_or_update` round-trip. ### Bugs Fixed +- Fixed `MLClient.jobs.create_or_update(job)` and `MLClient.jobs.archive`/`restore` failing for jobs previously fetched via `get(name)` across AutoML, Command, Pipeline, Sweep, Spark, and Parallel job types. These operations now route metadata-only changes through the RunHistory PATCH endpoint used by Azure ML Studio's portal, avoiding the MFE PUT round-trip that produced serializer / comparator errors (e.g. `queueSettings.jobTier` enum errors on Sweep, `The Uri field is required` on AutoML, InputBindings errors on Pipeline). ### Other Changes diff --git a/sdk/ml/azure-ai-ml/api.md b/sdk/ml/azure-ai-ml/api.md index d339f6b6a5bc..3b7980e17485 100644 --- a/sdk/ml/azure-ai-ml/api.md +++ b/sdk/ml/azure-ai-ml/api.md @@ -10934,6 +10934,18 @@ namespace azure.ai.ml.operations @monitor_with_activity(ops_logger, 'Job.Stream', ActivityType.PUBLICAPI) def stream(self, name: str) -> None: ... + @distributed_trace + @monitor_with_telemetry_mixin(ops_logger, 'Job.Update', ActivityType.PUBLICAPI) + def update( + self, + name: str, + *, + display_name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + properties: Optional[Dict[str, str]] = None + ) -> Job: ... + @distributed_trace @monitor_with_telemetry_mixin(ops_logger, 'Job.Validate', ActivityType.PUBLICAPI) def validate( diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py index abe990239839..914f745104ac 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py @@ -654,7 +654,7 @@ def error_func(msg: str, no_personal_data_msg: str) -> ValidationException: @distributed_trace @monitor_with_telemetry_mixin(ops_logger, "Job.CreateOrUpdate", ActivityType.PUBLICAPI) - def create_or_update( + def create_or_update( # pylint: disable=too-many-branches self, job: Job, *, @@ -739,12 +739,8 @@ def create_or_update( # are not supported by the shortcut and were rejected by MFE anyway). # Any failure inside the shortcut falls through to the original path so # brand-new job creation behavior is preserved bit-for-bit. - if ( - getattr(job, "id", None) - and getattr(job, "name", None) - and compute is None - and experiment_name is None - ): + if getattr(job, "id", None) and getattr(job, "name", None) and compute is None and experiment_name is None: + patch_succeeded = False try: job_object = self._get_job(job.name) if job_object.properties.job_type == RestJobType.PIPELINE: @@ -767,17 +763,24 @@ def create_or_update( properties=getattr(job, "properties", None), ), ) + patch_succeeded = True + except PipelineChildJobError: + raise + except Exception: # pylint: disable=broad-exception-caught + # Only failures BEFORE and INCLUDING the RunHistory PATCH are swallowed + # (job not found, transient RunHistory error, etc.) so we can fall + # through to the original code path without regressing creation. + pass + if patch_succeeded: + # PATCH already persisted the metadata change server-side. Any failure + # in the refresh/resolve below must surface directly — falling back to + # the original MFE PUT path would re-execute the very round-trip this + # hotfix exists to avoid and would surface a misleading error. refreshed = self._get_job(job.name) if refreshed.properties.job_type == RestJobType.PIPELINE: refreshed = self._get_job_2401(job.name) return self._resolve_azureml_id(Job._from_rest_object(refreshed)) - except PipelineChildJobError: - raise - except Exception: # noqa: BLE001 - deliberate broad catch; fall through - # Any failure (job not found, transient RunHistory error, etc.) falls - # through to the original code path so we never regress creation. - pass # ------------------ END TEMPORARY HOTFIX -------------------------------- if job.compute == LOCAL_COMPUTE_TARGET: @@ -907,8 +910,34 @@ def _archive_or_restore(self, name: str, is_archived: bool) -> None: job_object = self._get_job_2401(name) if _is_pipeline_child_job(job_object): raise PipelineChildJobError(job_id=job_object.id) - job_object.properties.is_archived = is_archived + # ---------------- TEMPORARY HOTFIX (archive/restore shortcut) ---------------- + # The legacy _create_or_update_with_different_version_api PUT round-trip fails + # on AutoML, Command, Sweep, and Spark jobs for the same serializer/comparator + # reasons as create_or_update (see the shortcut in create_or_update above). + # RunHistory's "hidden" flag is the same server-side field surfaced by MFE as + # properties.isArchived (verified via probe_runhistory_hidden.py), so we can + # PATCH it directly - which is what ML Studio's portal does for the Archive + # button. Any failure falls through to the original path so behavior is + # preserved for Pipeline (which already works via _get_job_2401). + try: + from azure.ai.ml._restclient.runhistory.models import CreateRun + + self._runs_operations._operation.add_or_modify_by_experiment_name( + subscription_id=self._operation_scope.subscription_id, + resource_group_name=self._operation_scope.resource_group_name, + workspace_name=self._workspace_name, + experiment_name=job_object.properties.experiment_name, + run_id=name, + body=CreateRun(hidden=is_archived), + ) + return + except Exception: # pylint: disable=broad-exception-caught + # Fall through to the original code path on any RunHistory error. + pass + # ------------------ END TEMPORARY HOTFIX -------------------------------- + + job_object.properties.is_archived = is_archived self._create_or_update_with_different_version_api(rest_job_resource=job_object) @distributed_trace From 2ce1e809fb325803a65db07f73e081282b0985a7 Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Sun, 5 Jul 2026 21:58:11 +0530 Subject: [PATCH 3/8] [ml] Fix CI: mypy job.name Optional + regenerate api.md/api.metadata.yml --- sdk/ml/azure-ai-ml/api.md | 8 ++++---- sdk/ml/azure-ai-ml/api.metadata.yml | 2 +- .../azure/ai/ml/operations/_job_operations.py | 11 ++++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/sdk/ml/azure-ai-ml/api.md b/sdk/ml/azure-ai-ml/api.md index 3b7980e17485..576a38619750 100644 --- a/sdk/ml/azure-ai-ml/api.md +++ b/sdk/ml/azure-ai-ml/api.md @@ -10940,10 +10940,10 @@ namespace azure.ai.ml.operations self, name: str, *, - display_name: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[Dict[str, str]] = None, - properties: Optional[Dict[str, str]] = None + description: Optional[str] = ..., + display_name: Optional[str] = ..., + properties: Optional[Dict[str, str]] = ..., + tags: Optional[Dict[str, str]] = ... ) -> Job: ... @distributed_trace diff --git a/sdk/ml/azure-ai-ml/api.metadata.yml b/sdk/ml/azure-ai-ml/api.metadata.yml index 3c170cd25905..dfa90d4353c3 100644 --- a/sdk/ml/azure-ai-ml/api.metadata.yml +++ b/sdk/ml/azure-ai-ml/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: dc079c94f44c28406c444f39bd5501c997e56e593fd93c3176b08ca3c3ef04d3 +apiMdSha256: 3c18a6479732e3c5eeec4dcbe3843d55b165b8a4cc90325d9f43c74d488f4769 parserVersion: 0.3.28 pythonVersion: 3.12.10 diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py index 914f745104ac..4839f95736f7 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py @@ -740,11 +740,12 @@ def create_or_update( # pylint: disable=too-many-branches # Any failure inside the shortcut falls through to the original path so # brand-new job creation behavior is preserved bit-for-bit. if getattr(job, "id", None) and getattr(job, "name", None) and compute is None and experiment_name is None: + job_name: str = cast(str, job.name) patch_succeeded = False try: - job_object = self._get_job(job.name) + job_object = self._get_job(job_name) if job_object.properties.job_type == RestJobType.PIPELINE: - job_object = self._get_job_2401(job.name) + job_object = self._get_job_2401(job_name) if _is_pipeline_child_job(job_object): raise PipelineChildJobError(job_id=job_object.id) @@ -755,7 +756,7 @@ def create_or_update( # pylint: disable=too-many-branches resource_group_name=self._operation_scope.resource_group_name, workspace_name=self._workspace_name, experiment_name=job_object.properties.experiment_name, - run_id=job.name, + run_id=job_name, body=CreateRun( display_name=getattr(job, "display_name", None), description=getattr(job, "description", None), @@ -777,9 +778,9 @@ def create_or_update( # pylint: disable=too-many-branches # in the refresh/resolve below must surface directly — falling back to # the original MFE PUT path would re-execute the very round-trip this # hotfix exists to avoid and would surface a misleading error. - refreshed = self._get_job(job.name) + refreshed = self._get_job(job_name) if refreshed.properties.job_type == RestJobType.PIPELINE: - refreshed = self._get_job_2401(job.name) + refreshed = self._get_job_2401(job_name) return self._resolve_azureml_id(Job._from_rest_object(refreshed)) # ------------------ END TEMPORARY HOTFIX -------------------------------- From 0bfd5b3c617a6e3a0ed2c5ae1c1456f330e4fcf5 Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Sun, 5 Jul 2026 22:12:21 +0530 Subject: [PATCH 4/8] [ml] Fix api.metadata.yml SHA to match CI-normalized computation --- sdk/ml/azure-ai-ml/api.metadata.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/ml/azure-ai-ml/api.metadata.yml b/sdk/ml/azure-ai-ml/api.metadata.yml index dfa90d4353c3..9549c7c79293 100644 --- a/sdk/ml/azure-ai-ml/api.metadata.yml +++ b/sdk/ml/azure-ai-ml/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 3c18a6479732e3c5eeec4dcbe3843d55b165b8a4cc90325d9f43c74d488f4769 +apiMdSha256: 7262033cadec8b494dfa9cca72d97a9dad84e1e465ce8c455782d9fab275a81f parserVersion: 0.3.28 pythonVersion: 3.12.10 From a78eeeb98f3a39d4007e52b11463b4f4c905ea2d Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Mon, 6 Jul 2026 15:11:47 +0530 Subject: [PATCH 5/8] [ml] Add unit tests for jobs.update() and create_or_update RunHistory shortcut --- .../unittests/test_job_operations.py | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py b/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py index 61eb51714df0..5d7beac5b45b 100644 --- a/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py +++ b/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py @@ -266,6 +266,157 @@ def test_restore(self, mock_method, mock_job_operation: JobOperations) -> None: mock_job_operation.service_client_01_2024_preview.jobs.get.assert_called_once() mock_job_operation._operation_2023_02_preview.create_or_update.assert_called_once() + # -------------- jobs.update() (new public API) -------------- + + def test_update_no_field_raises_user_error(self, mock_job_operation: JobOperations) -> None: + """update() with no updatable keyword must raise UserErrorException without any service calls.""" + from azure.ai.ml.exceptions import UserErrorException + + with pytest.raises(UserErrorException, match="at least one"): + mock_job_operation.update(name="random_name") + + @patch.object(Job, "_from_rest_object") + @patch.object(JobOperations, "_get_job") + def test_update_routes_through_runhistory_patch( + self, mock_get_job, mock_from_rest, mock_job_operation: JobOperations + ) -> None: + """update() with fields must invoke add_or_modify_by_experiment_name exactly once with + the correct experiment_name / run_id and a CreateRun body carrying the supplied fields.""" + from azure.ai.ml._restclient.v2023_08_01_preview.models import JobType as RestJobType + + fake_job = Mock() + fake_job.properties.job_type = RestJobType.COMMAND + fake_job.properties.experiment_name = "exp1" + mock_get_job.return_value = fake_job + mock_from_rest.return_value = Command(component=None) + # Force the property to return a fresh mock so add_or_modify_by_experiment_name is + # assertable (bypasses the lazy RunOperations construction that would fail on mocks). + mock_job_operation._runs_operations_client = Mock() + + mock_job_operation.update( + name="random_name", + display_name="new dn", + description="new desc", + tags={"k": "v"}, + properties={"pk": "pv"}, + ) + + add_mod = mock_job_operation._runs_operations._operation.add_or_modify_by_experiment_name + add_mod.assert_called_once() + call_kwargs = add_mod.call_args.kwargs + assert call_kwargs["experiment_name"] == "exp1" + assert call_kwargs["run_id"] == "random_name" + body = call_kwargs["body"] + assert body.display_name == "new dn" + assert body.description == "new desc" + assert body.tags == {"k": "v"} + assert body.properties == {"pk": "pv"} + + @patch.object(Job, "_from_rest_object") + @patch.object(JobOperations, "_get_job_2401") + @patch.object(JobOperations, "_get_job") + def test_update_pipeline_uses_2401_refetch( + self, + mock_get_job, + mock_get_job_2401, + mock_from_rest, + mock_job_operation: JobOperations, + ) -> None: + """PIPELINE jobs must be re-fetched via _get_job_2401 to obtain the non-projected view + before the RunHistory PATCH is issued.""" + from azure.ai.ml._restclient.v2023_08_01_preview.models import JobType as RestJobType + + pipeline_job = Mock() + pipeline_job.properties.job_type = RestJobType.PIPELINE + pipeline_job.properties.experiment_name = "exp1" + mock_get_job.return_value = pipeline_job + mock_get_job_2401.return_value = pipeline_job + mock_from_rest.return_value = Command(component=None) + mock_job_operation._runs_operations_client = Mock() + + mock_job_operation.update(name="random_name", display_name="new dn") + + # _get_job_2401 is called at least once (initial resolve + refresh both hit it for + # PIPELINE jobs); the RunHistory PATCH must still be issued. + assert mock_get_job_2401.call_count >= 1 + mock_job_operation._runs_operations._operation.add_or_modify_by_experiment_name.assert_called_once() + + @patch.object(JobOperations, "_get_job_2401") + @patch.object(JobOperations, "_get_job") + def test_update_pipeline_child_raises( + self, mock_get_job, mock_get_job_2401, mock_job_operation: JobOperations + ) -> None: + """A pipeline child job (properties is None on the 2401 view) must raise + PipelineChildJobError and must NOT issue any RunHistory PATCH.""" + from azure.ai.ml._restclient.v2023_08_01_preview.models import JobType as RestJobType + from azure.ai.ml.exceptions import PipelineChildJobError + + parent_view = Mock() + parent_view.properties.job_type = RestJobType.PIPELINE + mock_get_job.return_value = parent_view + + child = Mock() + child.properties = None # _is_pipeline_child_job -> True + child.id = "/fake/child/id" + mock_get_job_2401.return_value = child + + mock_job_operation._runs_operations_client = Mock() + + with pytest.raises(PipelineChildJobError): + mock_job_operation.update(name="child_run", description="x") + + mock_job_operation._runs_operations._operation.add_or_modify_by_experiment_name.assert_not_called() + + # -------------- create_or_update metadata-only shortcut -------------- + + @patch.object(Job, "_from_rest_object") + @patch.object(JobOperations, "_resolve_azureml_id") + @patch.object(JobOperations, "_get_job") + def test_create_or_update_metadata_shortcut_uses_runhistory_patch( + self, + mock_get_job, + mock_resolve, + mock_from_rest, + mock_job_operation: JobOperations, + ) -> None: + """For a Job that was previously fetched (has an ARM id) and is being resubmitted with + only metadata edits (no compute/experiment_name change), create_or_update must route + through the RunHistory PATCH shortcut and skip the legacy MFE PUT round-trip.""" + from azure.ai.ml._restclient.v2023_08_01_preview.models import JobType as RestJobType + + fake_job = Mock() + fake_job.properties.job_type = RestJobType.COMMAND + fake_job.properties.experiment_name = "exp1" + mock_get_job.return_value = fake_job + mock_from_rest.return_value = Command(component=None) + mock_resolve.return_value = Command(component=None) + mock_job_operation._runs_operations_client = Mock() + + input_job = Mock() + input_job.id = ( + "/subscriptions/x/resourceGroups/y/providers/Microsoft.MachineLearningServices" + "/workspaces/z/jobs/random_name" + ) + input_job.name = "random_name" + input_job.display_name = "edited dn" + input_job.description = "edited desc" + input_job.tags = {"k": "v"} + input_job.properties = {"pk": "pv"} + input_job.compute = None + + mock_job_operation.create_or_update(job=input_job) + + add_mod = mock_job_operation._runs_operations._operation.add_or_modify_by_experiment_name + add_mod.assert_called_once() + body = add_mod.call_args.kwargs["body"] + assert body.display_name == "edited dn" + assert body.description == "edited desc" + assert body.tags == {"k": "v"} + assert body.properties == {"pk": "pv"} + # Legacy MFE PUT paths must be untouched when the shortcut succeeds. + mock_job_operation.service_client_01_2024_preview.jobs.create_or_update.assert_not_called() + mock_job_operation._operation_2023_02_preview.create_or_update.assert_not_called() + @pytest.mark.parametrize( "corrupt_job_data", [ From bc2a2d97e62940037fea88e48096001645893f18 Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Mon, 6 Jul 2026 15:16:11 +0530 Subject: [PATCH 6/8] [ml] jobs.update(): resolve returned entity via _resolve_azureml_id for consistency with jobs.get() --- .../azure/ai/ml/operations/_job_operations.py | 2 +- .../unittests/test_job_operations.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py index 4839f95736f7..f5751ab1a1cc 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py @@ -1046,7 +1046,7 @@ def update( refreshed = self._get_job(name) if refreshed.properties.job_type == RestJobType.PIPELINE: refreshed = self._get_job_2401(name) - return Job._from_rest_object(refreshed) + return self._resolve_azureml_id(Job._from_rest_object(refreshed)) @distributed_trace @monitor_with_activity(ops_logger, "Job.Stream", ActivityType.PUBLICAPI) diff --git a/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py b/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py index 5d7beac5b45b..f3ba5ef64299 100644 --- a/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py +++ b/sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py @@ -276,24 +276,29 @@ def test_update_no_field_raises_user_error(self, mock_job_operation: JobOperatio mock_job_operation.update(name="random_name") @patch.object(Job, "_from_rest_object") + @patch.object(JobOperations, "_resolve_azureml_id") @patch.object(JobOperations, "_get_job") def test_update_routes_through_runhistory_patch( - self, mock_get_job, mock_from_rest, mock_job_operation: JobOperations + self, mock_get_job, mock_resolve, mock_from_rest, mock_job_operation: JobOperations ) -> None: """update() with fields must invoke add_or_modify_by_experiment_name exactly once with - the correct experiment_name / run_id and a CreateRun body carrying the supplied fields.""" + the correct experiment_name / run_id and a CreateRun body carrying the supplied fields. + The returned entity must also be routed through _resolve_azureml_id so callers get the + same resolved view they'd get from jobs.get().""" from azure.ai.ml._restclient.v2023_08_01_preview.models import JobType as RestJobType fake_job = Mock() fake_job.properties.job_type = RestJobType.COMMAND fake_job.properties.experiment_name = "exp1" mock_get_job.return_value = fake_job + resolved_job = Command(component=None) mock_from_rest.return_value = Command(component=None) + mock_resolve.return_value = resolved_job # Force the property to return a fresh mock so add_or_modify_by_experiment_name is # assertable (bypasses the lazy RunOperations construction that would fail on mocks). mock_job_operation._runs_operations_client = Mock() - mock_job_operation.update( + result = mock_job_operation.update( name="random_name", display_name="new dn", description="new desc", @@ -311,19 +316,23 @@ def test_update_routes_through_runhistory_patch( assert body.description == "new desc" assert body.tags == {"k": "v"} assert body.properties == {"pk": "pv"} + mock_resolve.assert_called_once() + assert result is resolved_job @patch.object(Job, "_from_rest_object") + @patch.object(JobOperations, "_resolve_azureml_id") @patch.object(JobOperations, "_get_job_2401") @patch.object(JobOperations, "_get_job") def test_update_pipeline_uses_2401_refetch( self, mock_get_job, mock_get_job_2401, + mock_resolve, mock_from_rest, mock_job_operation: JobOperations, ) -> None: """PIPELINE jobs must be re-fetched via _get_job_2401 to obtain the non-projected view - before the RunHistory PATCH is issued.""" + before the RunHistory PATCH is issued. The refreshed entity is then resolved.""" from azure.ai.ml._restclient.v2023_08_01_preview.models import JobType as RestJobType pipeline_job = Mock() @@ -332,6 +341,7 @@ def test_update_pipeline_uses_2401_refetch( mock_get_job.return_value = pipeline_job mock_get_job_2401.return_value = pipeline_job mock_from_rest.return_value = Command(component=None) + mock_resolve.return_value = Command(component=None) mock_job_operation._runs_operations_client = Mock() mock_job_operation.update(name="random_name", display_name="new dn") @@ -340,6 +350,7 @@ def test_update_pipeline_uses_2401_refetch( # PIPELINE jobs); the RunHistory PATCH must still be issued. assert mock_get_job_2401.call_count >= 1 mock_job_operation._runs_operations._operation.add_or_modify_by_experiment_name.assert_called_once() + mock_resolve.assert_called_once() @patch.object(JobOperations, "_get_job_2401") @patch.object(JobOperations, "_get_job") From 43925b5ccd078cb41ef9d2dbbb4ba8220be95365 Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Mon, 6 Jul 2026 16:29:08 +0530 Subject: [PATCH 7/8] [ml] ci: rerun to clear flaky macos311 test_automl_node_in_pipeline_classification From c75555e2b49d47bf16548f3551ee1969a188948c Mon Sep 17 00:00:00 2001 From: Chakradhar Date: Tue, 7 Jul 2026 09:44:04 +0530 Subject: [PATCH 8/8] [ml] Shorten 1.35.0 CHANGELOG to bug-fix only per review --- sdk/ml/azure-ai-ml/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index fa871a5a8c05..0193086da0ad 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -3,10 +3,9 @@ ## 1.35.0 (unreleased) ### Features Added -- Added `MLClient.jobs.update(name, *, display_name=None, description=None, tags=None, properties=None)` to partially update a job's mutable metadata (display name, description, tags, properties) without a full `create_or_update` round-trip. ### Bugs Fixed -- Fixed `MLClient.jobs.create_or_update(job)` and `MLClient.jobs.archive`/`restore` failing for jobs previously fetched via `get(name)` across AutoML, Command, Pipeline, Sweep, Spark, and Parallel job types. These operations now route metadata-only changes through the RunHistory PATCH endpoint used by Azure ML Studio's portal, avoiding the MFE PUT round-trip that produced serializer / comparator errors (e.g. `queueSettings.jobTier` enum errors on Sweep, `The Uri field is required` on AutoML, InputBindings errors on Pipeline). +- Fixed `MLClient.jobs.create_or_update`, `archive`, and `restore` failing for previously-fetched jobs across all job types by routing metadata-only edits through the RunHistory PATCH endpoint. ### Other Changes