Repository: mlflow/mlflow

Issue:
[BUG] log_params fails when logging a parameter that already exists, but without changing its value
### MLflow version

1.29.0

### System information

- **Python version**: 3.8


### Describe the problem

log_params fails when I try to log again a parameter (but without changing its initial value)

### Code to reproduce issue

```python
import mlflow
from mlflow.tracking.fluent import start_run
with start_run() as active_run:
    mlflow.log_params({"a": "1", "b": "2"})
    mlflow.log_params({"a": "1", "c": "2"})
```

You can reproduce this issue by adding a new test in tests/tracking/test_tracking.py with the code above.

### Stack trace

```python
  File "mlflow/mlflow/tracking/fluent.py", line 701, in log_params
    MlflowClient().log_batch(run_id=run_id, metrics=[], params=params_arr, tags=[])
  File "mlflow/mlflow/tracking/client.py", line 1034, in log_batch
    self._tracking_client.log_batch(run_id, metrics, params, tags)
  File "mlflow/mlflow/tracking/_tracking_service/client.py", line 372, in log_batch
    self.store.log_batch(
  File "mlflow/mlflow/store/tracking/sqlalchemy_store.py", line 1186, in log_batch
    raise e
  File "mlflow/mlflow/store/tracking/sqlalchemy_store.py", line 1182, in log_batch
    self._log_params(run_id, params)
  File "mlflow/mlflow/store/tracking/sqlalchemy_store.py", line 984, in _log_params
    raise MlflowException(
mlflow.exceptions.MlflowException: Changing param values is not allowed. Params were already logged='[{'key': 'c', 'old_value': None, 'new_value': '2'}]' for run ID='40d656aa104a4f7ca00bc9bf9aa99630'.
```

The error is misleading because 'c' was not logged before.


Discussion:
@bogdanalexandrescu Thanks for reporting the issue. I was able to reproduce it. I will file a PR.

Resolving patch:
diff --git a/mlflow/store/tracking/sqlalchemy_store.py b/mlflow/store/tracking/sqlalchemy_store.py
--- a/mlflow/store/tracking/sqlalchemy_store.py
+++ b/mlflow/store/tracking/sqlalchemy_store.py
@@ -962,52 +962,36 @@ def _log_params(self, run_id, params):
         if not params:
             return
 
-        param_instances = [
-            SqlParam(run_uuid=run_id, key=param.key, value=param.value) for param in params
-        ]
-
         with self.ManagedSessionMaker() as session:
             run = self._get_run(run_uuid=run_id, session=session)
             self._check_run_is_active(run)
-            # commit the session to make sure that we catch any IntegrityError
-            # and try to handle them.
-            try:
-                self._save_to_db(session=session, objs=param_instances)
-                session.commit()
-            except sqlalchemy.exc.IntegrityError:
-                # Roll back the current session to make it usable for further transactions. In the
-                # event of an error during "commit", a rollback is required in order to continue
-                # using the session. In this case, we re-use the session because the SqlRun, `run`,
-                # is lazily evaluated during the invocation of `run.params`.
-                session.rollback()
-
-                # in case of an integrity error, compare the parameters of the
-                # run. If the parameters match the ones whom being saved,
-                # ignore the exception since idempotency is reached.
-                # Also, multiple params for the same key can still be passed within
-                # the same batch. So, handle them by selecting the first param
-                # for the given key
-                run_params = {param.key: param.value for param in run.params}
-                non_matching_params = []
-                for param in param_instances:
-                    existing_value = run_params.get(param.key)
-                    if param.value != existing_value:
+            existing_params = {p.key: p.value for p in run.params}
+            new_params = []
+            non_matching_params = []
+            for param in params:
+                if param.key in existing_params:
+                    if param.value != existing_params[param.key]:
                         non_matching_params.append(
                             {
                                 "key": param.key,
-                                "old_value": existing_value,
+                                "old_value": existing_params[param.key],
                                 "new_value": param.value,
                             }
                         )
+                    continue
+                new_params.append(SqlParam(run_uuid=run_id, key=param.key, value=param.value))
 
-                if non_matching_params:
-                    raise MlflowException(
-                        "Changing param values is not allowed. Params were already logged='{}'"
-                        " for run ID='{}'.".format(non_matching_params, run_id),
-                        INVALID_PARAMETER_VALUE,
-                    )
-                # if there's no mismatch, do not raise an Exception since
-                # we are sure that idempotency is reached.
+            if non_matching_params:
+                raise MlflowException(
+                    "Changing param values is not allowed. Params were already logged='{}'"
+                    " for run ID='{}'.".format(non_matching_params, run_id),
+                    INVALID_PARAMETER_VALUE,
+                )
+
+            if not new_params:
+                return
+
+            self._save_to_db(session=session, objs=new_params)
 
     def set_experiment_tag(self, experiment_id, tag):
         """


Test patch:
diff --git a/tests/store/tracking/test_sqlalchemy_store.py b/tests/store/tracking/test_sqlalchemy_store.py
--- a/tests/store/tracking/test_sqlalchemy_store.py
+++ b/tests/store/tracking/test_sqlalchemy_store.py
@@ -2218,6 +2218,35 @@ def test_log_batch_param_overwrite_disallowed(self):
         assert exception_context.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
         self._verify_logged(self.store, run.info.run_id, metrics=[], params=[param], tags=[])
 
+    def test_log_batch_with_unchanged_and_new_params(self):
+        """
+        Test case to ensure the following code works:
+        ---------------------------------------------
+        mlflow.log_params({"a": 0, "b": 1})
+        mlflow.log_params({"a": 0, "c": 2})
+        ---------------------------------------------
+        """
+        run = self._run_factory()
+        self.store.log_batch(
+            run.info.run_id,
+            metrics=[],
+            params=[entities.Param("a", "0"), entities.Param("b", "1")],
+            tags=[],
+        )
+        self.store.log_batch(
+            run.info.run_id,
+            metrics=[],
+            params=[entities.Param("a", "0"), entities.Param("c", "2")],
+            tags=[],
+        )
+        self._verify_logged(
+            self.store,
+            run.info.run_id,
+            metrics=[],
+            params=[entities.Param("a", "0"), entities.Param("b", "1"), entities.Param("c", "2")],
+            tags=[],
+        )
+
     def test_log_batch_param_overwrite_disallowed_single_req(self):
         # Test that attempting to overwrite a param via log_batch results in an exception
         run = self._run_factory()