snowflake-ml-python 2.2.0__py3-none-any.whl → 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,7 @@ class ExperimentTracking:
52
52
  database_name: str | None = None,
53
53
  schema_name: str | None = None,
54
54
  capture_source_info: bool = True,
55
+ model_registry: registry.Registry | None = None,
55
56
  ) -> None:
56
57
  """
57
58
  Initializes experiment tracking within a pre-created schema.
@@ -68,6 +69,12 @@ class ExperimentTracking:
68
69
  of the enclosing ML job when running inside one. Set to False to skip collection
69
70
  entirely. Collection is always non-fatal and never blocks run creation. Defaults to
70
71
  True.
72
+ model_registry: An optional Registry instance to use for ``log_model``. When provided,
73
+ models are logged to the database and schema owned by this registry instead of the
74
+ experiment's database and schema. This is useful when the experiment lives in one
75
+ schema (e.g. ``analytics.experiments``) but models should be stored in another
76
+ (e.g. ``ml.models``). If None, models are logged to the same location as the
77
+ experiment. Defaults to None.
71
78
 
72
79
  Raises:
73
80
  ValueError: If no database is provided and no active database exists in the session.
@@ -109,11 +116,7 @@ class ExperimentTracking:
109
116
  database_name=self._database_name,
110
117
  schema_name=self._schema_name,
111
118
  )
112
- self._registry = registry.Registry(
113
- session=session,
114
- database_name=self._database_name,
115
- schema_name=self._schema_name,
116
- )
119
+ self._registry = model_registry
117
120
  self._session = session
118
121
 
119
122
  # The experiment in context
@@ -139,7 +142,10 @@ class ExperimentTracking:
139
142
  else:
140
143
  state = dict(self.__dict__)
141
144
 
142
- # Remove unpicklable attributes
145
+ # Remove unpicklable attributes; save registry coordinates for reconstruction.
146
+ if self._registry is not None:
147
+ state["_registry_database_name"] = self._registry._database_name
148
+ state["_registry_schema_name"] = self._registry._schema_name
143
149
  state["_session"] = None
144
150
  state["_sql_client"] = None
145
151
  state["_registry"] = None
@@ -168,11 +174,14 @@ class ExperimentTracking:
168
174
  database_name=self._database_name,
169
175
  schema_name=self._schema_name,
170
176
  )
171
- self._registry = registry.Registry(
172
- session=self._session,
173
- database_name=self._database_name,
174
- schema_name=self._schema_name,
175
- )
177
+ registry_db = state.pop("_registry_database_name", None)
178
+ registry_schema = state.pop("_registry_schema_name", None)
179
+ if registry_db is not None:
180
+ self._registry = registry.Registry(
181
+ session=self._session,
182
+ database_name=registry_db,
183
+ schema_name=registry_schema,
184
+ )
176
185
  if self._run:
177
186
  self._patch_stdout_and_stderr()
178
187
  # _patch_stdout_and_stderr sets _logging_context if live logging is enabled
@@ -277,8 +286,17 @@ class ExperimentTracking:
277
286
  **kwargs: Any,
278
287
  ) -> ml_model.ModelVersion:
279
288
  run = self._get_or_start_run()
289
+ target_registry = (
290
+ self._registry
291
+ if self._registry is not None
292
+ else registry.Registry(
293
+ session=self._session,
294
+ database_name=self._database_name,
295
+ schema_name=self._schema_name,
296
+ )
297
+ )
280
298
  with experiment_info.ExperimentInfoPatcher(experiment_info=run._get_experiment_info()):
281
- return self._registry.log_model(model, model_name=model_name, **kwargs)
299
+ return target_registry.log_model(model, model_name=model_name, **kwargs)
282
300
 
283
301
  def list_model_versions(self, run_name: str | None = None) -> list[ml_model.ModelVersion]:
284
302
  """
@@ -784,11 +802,6 @@ class ExperimentTracking:
784
802
  database_name=database_name,
785
803
  schema_name=schema_name,
786
804
  )
787
- self._registry = registry.Registry(
788
- session=self._session,
789
- database_name=database_name,
790
- schema_name=schema_name,
791
- )
792
805
 
793
806
  def _print_urls(
794
807
  self,
@@ -1143,6 +1143,34 @@ class MergingSqlGenerator:
1143
1143
 
1144
1144
  return cte_name, cte_body.strip()
1145
1145
 
1146
+ def _left_join_subqueries_on_bounds(self, subqueries: list[tuple[str, list[str]]]) -> str:
1147
+ """LEFT JOIN each per-subquery result onto the unique (entity, boundary) rows.
1148
+
1149
+ Driving from UNIQUE_BOUNDS rather than from the first subquery keeps row
1150
+ presence independent of any single window or feature: each subquery applies
1151
+ its own row-level filter, so one with no qualifying tiles must contribute
1152
+ NULLs for its own columns instead of dropping the entity from the CTE and
1153
+ taking every other subquery's results with it.
1154
+
1155
+ Args:
1156
+ subqueries: One (subquery_sql, output_columns) pair per window group or
1157
+ feature. Each subquery must be grouped by the join keys plus
1158
+ TILE_BOUNDARY so the joins stay one-to-one.
1159
+
1160
+ Returns:
1161
+ SQL string selecting the join keys plus every subquery's output columns.
1162
+ """
1163
+ group_by_cols = list(self._join_keys) + [_TILE_BOUNDARY_COL]
1164
+ projected = [f"UB.{col}" for col in group_by_cols]
1165
+ for index, (_, output_cols) in enumerate(subqueries):
1166
+ projected.extend(f"sq{index}.{col}" for col in output_cols)
1167
+
1168
+ result = f"SELECT {', '.join(projected)}\n FROM UNIQUE_BOUNDS_FV{self._fv_index} UB"
1169
+ for index, (query, _) in enumerate(subqueries):
1170
+ join_cond = " AND ".join(f"UB.{col} = sq{index}.{col}" for col in group_by_cols)
1171
+ result += f"\n LEFT JOIN {query} sq{index}\n ON {join_cond}"
1172
+ return result
1173
+
1146
1174
  def _generate_secondary_key_merged_cte(self) -> tuple[str, str]:
1147
1175
  """Generate the CTE for secondary-key aggregations.
1148
1176
 
@@ -1315,22 +1343,13 @@ class MergingSqlGenerator:
1315
1343
  if len(subqueries) == 1:
1316
1344
  cte_body = subqueries[0][2].strip()
1317
1345
  else:
1318
- # Multiple (sk, window) groups — LEFT JOIN on (entity_keys, boundary).
1319
- _, _, first_query = subqueries[0]
1320
- projected: list[str] = [f"sq0.{col}" for col in group_by_cols]
1321
- projected.append(f"sq0.{subqueries[0][0]}")
1322
- for col in subqueries[0][1]:
1323
- projected.append(f"sq0.{col}")
1324
- for i, (keys_col, value_cols, _) in enumerate(subqueries[1:], 1):
1325
- projected.append(f"sq{i}.{keys_col}")
1326
- for col in value_cols:
1327
- projected.append(f"sq{i}.{col}")
1328
-
1329
- result = f"SELECT {', '.join(projected)}\n FROM {first_query} sq0"
1330
- for i, (_, _, query) in enumerate(subqueries[1:], 1):
1331
- join_cond = " AND ".join(f"sq0.{col} = sq{i}.{col}" for col in group_by_cols)
1332
- result += f"\n LEFT JOIN {query} sq{i}\n ON {join_cond}"
1333
- cte_body = result
1346
+ # Multiple (sk, window) groups: a group whose window covers no tiles
1347
+ # emits no row, so the chain is driven from the unique (entity,
1348
+ # boundary) rows to keep each group's arrays independent. Missing
1349
+ # groups surface as [] via the combined CTE's COALESCE.
1350
+ cte_body = self._left_join_subqueries_on_bounds(
1351
+ [(query, [keys_col, *value_cols]) for keys_col, value_cols, query in subqueries]
1352
+ )
1334
1353
 
1335
1354
  return cte_name, cte_body.strip()
1336
1355
 
@@ -1574,21 +1593,11 @@ class MergingSqlGenerator:
1574
1593
  if len(feature_subqueries) == 1:
1575
1594
  return feature_subqueries[0][1]
1576
1595
 
1577
- # Multiple list features: join them together
1578
- first_name, first_query = feature_subqueries[0]
1579
- result = "SELECT sq0.*, "
1580
- for i, (name, _) in enumerate(feature_subqueries[1:], 1):
1581
- result += f"sq{i}.{name}"
1582
- if i < len(feature_subqueries) - 1:
1583
- result += ", "
1584
-
1585
- result += f"\n FROM {first_query} sq0"
1586
-
1587
- for i, (_name, query) in enumerate(feature_subqueries[1:], 1):
1588
- join_cond = " AND ".join(f"sq0.{col} = sq{i}.{col}" for col in group_by_cols)
1589
- result += f"\n LEFT JOIN {query} sq{i}\n ON {join_cond}"
1590
-
1591
- return result
1596
+ # Multiple list features: a feature with no non-NULL values in the window
1597
+ # emits no row, so the chain is driven from the unique (entity, boundary)
1598
+ # rows to keep each feature's array independent of the others. A missing
1599
+ # feature stays NULL, which is this path's "no data" encoding.
1600
+ return self._left_join_subqueries_on_bounds([(query, [name]) for name, query in feature_subqueries])
1592
1601
 
1593
1602
  def _generate_combined_cte(self) -> tuple[str, str]:
1594
1603
  """Generate the final CTE that combines and expands results.
snowflake/ml/jobs/job.py CHANGED
@@ -362,13 +362,29 @@ class MLJob(Generic[T], SerializableSessionMixin):
362
362
  """
363
363
  if self._result is None:
364
364
  self.wait(timeout)
365
+ # Retrying would push the call past the caller's timeout, so only retry when unbounded.
366
+ remaining = _INSTANCE_RECORD_TIMEOUT_SECONDS if timeout < 0 else 0
365
367
  try:
366
- self._result = interop_utils.load(
367
- self._result_path, session=self._session, path_transform=self._transform_path
368
- )
368
+ while True:
369
+ try:
370
+ self._result = interop_utils.load(
371
+ self._result_path, session=self._session, path_transform=self._transform_path
372
+ )
373
+ break
374
+ except (OSError, json.JSONDecodeError) as error:
375
+ if remaining <= 0:
376
+ raise
377
+ logger.warning(
378
+ "job result retrieval: result is not yet readable: %s; retrying in %d second(s)",
379
+ error,
380
+ _INSTANCE_RECORD_POLL_INTERVAL_SECONDS,
381
+ )
382
+ time.sleep(_INSTANCE_RECORD_POLL_INTERVAL_SECONDS)
383
+ remaining -= _INSTANCE_RECORD_POLL_INTERVAL_SECONDS
369
384
  except Exception as e:
370
385
  raise RuntimeError(f"Failed to retrieve result for job, error: {e!r}") from e
371
386
 
387
+ assert self._result is not None
372
388
  return cast(T, self._result.get_value())
373
389
 
374
390
  @telemetry.send_api_usage_telemetry(project=_PROJECT, func_params_to_log=["timeout"])
@@ -5,7 +5,7 @@ import os
5
5
  import pathlib
6
6
  import tempfile
7
7
  import warnings
8
- from typing import Any, Literal, TypedDict, cast, overload
8
+ from typing import Any, Literal, NoReturn, TypedDict, cast, overload
9
9
 
10
10
  import yaml
11
11
  from typing_extensions import NotRequired
@@ -31,7 +31,13 @@ from snowflake.ml.model._packager.model_env import model_env
31
31
  from snowflake.ml.model._packager.model_meta import model_meta
32
32
  from snowflake.ml.model._packager.model_runtime import model_runtime
33
33
  from snowflake.ml.model._signatures import snowpark_handler
34
- from snowflake.snowpark import dataframe, functions as F, row, session
34
+ from snowflake.snowpark import (
35
+ dataframe,
36
+ exceptions as snowpark_exceptions,
37
+ functions as F,
38
+ row,
39
+ session,
40
+ )
35
41
  from snowflake.snowpark._internal import utils as snowpark_utils
36
42
 
37
43
  logger = logging.getLogger(__name__)
@@ -986,6 +992,220 @@ class ModelOperator:
986
992
  task_val = model_attributes.get("task", type_hints.Task.UNKNOWN.value)
987
993
  return type_hints.Task(task_val)
988
994
 
995
+ @staticmethod
996
+ def _is_peft_adapter_spec(parsed_model_spec: model_spec.ModelSpec) -> bool:
997
+ """Return whether the specification describes a PEFT adapter version.
998
+
999
+ Args:
1000
+ parsed_model_spec: Parsed model specification from SHOW VERSIONS.
1001
+
1002
+ Returns:
1003
+ True when the model type is peft_adapter or blob options name a pin.
1004
+ """
1005
+ try:
1006
+ if parsed_model_spec.model_type == "peft_adapter":
1007
+ return True
1008
+ except (KeyError, ValueError):
1009
+ pass
1010
+ return any(
1011
+ isinstance(options.get("base_model_name"), str)
1012
+ and options.get("base_model_name")
1013
+ and isinstance(options.get("base_model_version"), str)
1014
+ and options.get("base_model_version")
1015
+ for options in parsed_model_spec.model_options.values()
1016
+ )
1017
+
1018
+ def _peft_pin_identity_from_lineage(
1019
+ self,
1020
+ *,
1021
+ database_name: sql_identifier.SqlIdentifier | None,
1022
+ schema_name: sql_identifier.SqlIdentifier | None,
1023
+ model_name: sql_identifier.SqlIdentifier,
1024
+ version_name: sql_identifier.SqlIdentifier,
1025
+ ) -> tuple[str, str] | Literal["absent", "blocked"]:
1026
+ """Resolve the adapter pin's current name from upstream model lineage.
1027
+
1028
+ Args:
1029
+ database_name: Database of the adapter version.
1030
+ schema_name: Schema of the adapter version.
1031
+ model_name: Adapter model name.
1032
+ version_name: Adapter version name.
1033
+
1034
+ Returns:
1035
+ Current pin fully-qualified name and version when exactly one ACTIVE
1036
+ upstream model exists; ``absent`` when lineage has no model upstream
1037
+ or cannot be read; ``blocked`` when the pin is MASKED, DELETED, incomplete,
1038
+ or not unique.
1039
+ """
1040
+ try:
1041
+ trace_df = self._session.lineage.trace(
1042
+ self._model_client.fully_qualified_object_name(database_name, schema_name, model_name),
1043
+ "MODEL",
1044
+ object_version=version_name.identifier(),
1045
+ direction="upstream",
1046
+ distance=1,
1047
+ )
1048
+ rows = trace_df.collect()
1049
+ model_nodes: list[tuple[str, str, str]] = []
1050
+ for lineage_row in rows:
1051
+ lineage_object = json.loads(lineage_row["SOURCE_OBJECT"])
1052
+ if str(lineage_object.get("domain", "")).lower() != "model":
1053
+ continue
1054
+ raw_name = lineage_object.get("name")
1055
+ raw_version = lineage_object.get("version")
1056
+ status = str(lineage_object.get("status", ""))
1057
+ if not isinstance(raw_name, str) or not raw_name:
1058
+ model_nodes.append(("", "", status))
1059
+ continue
1060
+ parsed_name = ".".join(
1061
+ identifier.rename_to_valid_snowflake_identifier(part)
1062
+ for part in identifier.parse_schema_level_object_identifier(raw_name)
1063
+ if part is not None
1064
+ )
1065
+ pin_version = raw_version if isinstance(raw_version, str) else ""
1066
+ model_nodes.append((parsed_name, pin_version, status))
1067
+ except (
1068
+ AttributeError,
1069
+ KeyError,
1070
+ TypeError,
1071
+ ValueError,
1072
+ json.JSONDecodeError,
1073
+ snowpark_exceptions.SnowparkSQLException,
1074
+ ) as err:
1075
+ logger.debug("adapter model: could not read pin lineage: %s", err)
1076
+ return "absent"
1077
+ if not model_nodes:
1078
+ return "absent"
1079
+ if len(model_nodes) != 1:
1080
+ return "blocked"
1081
+ pin_name, pin_version, status = model_nodes[0]
1082
+ if status.upper() != "ACTIVE" or not pin_name or not pin_version:
1083
+ return "blocked"
1084
+ return pin_name, pin_version
1085
+
1086
+ @staticmethod
1087
+ def _peft_pin_identity_from_blob_options(
1088
+ parsed_model_spec: model_spec.ModelSpec,
1089
+ ) -> tuple[str, str] | None:
1090
+ """Return the pin name and version from adapter blob options.
1091
+
1092
+ Args:
1093
+ parsed_model_spec: Adapter specification whose blob options may name the pin.
1094
+
1095
+ Returns:
1096
+ Pin fully-qualified name and version, or None when those options are missing.
1097
+ """
1098
+ for options in parsed_model_spec.model_options.values():
1099
+ name = options.get("base_model_name")
1100
+ version = options.get("base_model_version")
1101
+ if isinstance(name, str) and name and isinstance(version, str) and version:
1102
+ return name, version
1103
+ return None
1104
+
1105
+ def _fetch_model_spec_for_pin(
1106
+ self,
1107
+ pin_name: str,
1108
+ pin_version: str,
1109
+ *,
1110
+ statement_params: dict[str, Any] | None = None,
1111
+ ) -> model_spec.ModelSpec | None:
1112
+ """Load a pin specification by fully-qualified name and version.
1113
+
1114
+ Args:
1115
+ pin_name: Fully-qualified pin model name.
1116
+ pin_version: Pin version name.
1117
+ statement_params: Optional dictionary of statement parameters.
1118
+
1119
+ Returns:
1120
+ The pin specification, or None when the name is unparsable or the pin cannot be loaded.
1121
+ """
1122
+ try:
1123
+ database_name, schema_name, model_name = sql_identifier.parse_fully_qualified_name(pin_name)
1124
+ except ValueError:
1125
+ return None
1126
+ if database_name is None or schema_name is None:
1127
+ return None
1128
+ try:
1129
+ return self._fetch_model_spec(
1130
+ database_name=database_name,
1131
+ schema_name=schema_name,
1132
+ model_name=model_name,
1133
+ version_name=sql_identifier.SqlIdentifier(pin_version),
1134
+ statement_params=statement_params,
1135
+ )
1136
+ except (
1137
+ IndexError,
1138
+ KeyError,
1139
+ TypeError,
1140
+ ValueError,
1141
+ yaml.YAMLError,
1142
+ snowpark_exceptions.SnowparkSQLException,
1143
+ ) as err:
1144
+ logger.debug("adapter model: could not load pin specification: %s", err)
1145
+ return None
1146
+
1147
+ def _fetch_peft_pin_spec(
1148
+ self,
1149
+ parsed_model_spec: model_spec.ModelSpec,
1150
+ *,
1151
+ database_name: sql_identifier.SqlIdentifier | None,
1152
+ schema_name: sql_identifier.SqlIdentifier | None,
1153
+ model_name: sql_identifier.SqlIdentifier,
1154
+ version_name: sql_identifier.SqlIdentifier,
1155
+ statement_params: dict[str, Any] | None = None,
1156
+ ) -> model_spec.ModelSpec | None:
1157
+ """Load the pin model specification for an adapter version.
1158
+
1159
+ Prefers the current names from the adapter's upstream model lineage so a pin
1160
+ rename still resolves. Blob options are used only when lineage has no model
1161
+ upstream (the pin edge is not visible yet).
1162
+
1163
+ Args:
1164
+ parsed_model_spec: Adapter specification whose blob options may name the pin.
1165
+ database_name: Database of the adapter version.
1166
+ schema_name: Schema of the adapter version.
1167
+ model_name: Adapter model name.
1168
+ version_name: Adapter version name.
1169
+ statement_params: Optional dictionary of statement parameters.
1170
+
1171
+ Returns:
1172
+ The pin specification, or None when the pin cannot be resolved or loaded.
1173
+ """
1174
+ lineage_pin = self._peft_pin_identity_from_lineage(
1175
+ database_name=database_name,
1176
+ schema_name=schema_name,
1177
+ model_name=model_name,
1178
+ version_name=version_name,
1179
+ )
1180
+ if lineage_pin == "blocked":
1181
+ return None
1182
+ if lineage_pin == "absent":
1183
+ blob_pin = ModelOperator._peft_pin_identity_from_blob_options(parsed_model_spec)
1184
+ if blob_pin is None:
1185
+ return None
1186
+ pin_name, pin_version = blob_pin
1187
+ else:
1188
+ pin_name, pin_version = lineage_pin
1189
+ return self._fetch_model_spec_for_pin(
1190
+ pin_name,
1191
+ pin_version,
1192
+ statement_params=statement_params,
1193
+ )
1194
+
1195
+ @staticmethod
1196
+ def _raise_unresolved_adapter_pin_signatures() -> NoReturn:
1197
+ """Raise when adapter catalog methods cannot be mapped onto pin signatures.
1198
+
1199
+ Raises:
1200
+ SnowflakeMLException: Always. Catalog methods exist but pin signatures could not be used.
1201
+ """
1202
+ raise exceptions.SnowflakeMLException(
1203
+ error_code=error_codes.INVALID_ARGUMENT,
1204
+ original_exception=ValueError(
1205
+ "adapter model: catalog methods exist but method signatures could not be resolved from the pin."
1206
+ ),
1207
+ )
1208
+
989
1209
  def get_functions(
990
1210
  self,
991
1211
  *,
@@ -1058,20 +1278,47 @@ class ModelOperator:
1058
1278
  )
1059
1279
 
1060
1280
  signatures = parsed_model_spec.signatures
1281
+ signature_spec = parsed_model_spec
1282
+ is_peft_adapter = False
1283
+ if not signatures and function_names_and_types:
1284
+ is_peft_adapter = ModelOperator._is_peft_adapter_spec(parsed_model_spec)
1285
+ if is_peft_adapter:
1286
+ pin_spec = self._fetch_peft_pin_spec(
1287
+ parsed_model_spec,
1288
+ database_name=database_name,
1289
+ schema_name=schema_name,
1290
+ model_name=model_name,
1291
+ version_name=version_name,
1292
+ statement_params=statement_params,
1293
+ )
1294
+ if pin_spec is None or not pin_spec.signatures:
1295
+ ModelOperator._raise_unresolved_adapter_pin_signatures()
1296
+ signatures = pin_spec.signatures
1297
+ signature_spec = pin_spec
1298
+
1061
1299
  function_names = [name for name, _, _ in function_names_and_types]
1062
- function_name_mapping = ModelOperator._match_model_spec_with_sql_functions(
1063
- function_names, list(signatures.keys())
1064
- )
1300
+ try:
1301
+ function_name_mapping = ModelOperator._match_model_spec_with_sql_functions(
1302
+ function_names, list(signatures.keys())
1303
+ )
1304
+ except AssertionError:
1305
+ if is_peft_adapter:
1306
+ ModelOperator._raise_unresolved_adapter_pin_signatures()
1307
+ raise
1065
1308
 
1066
1309
  model_func_info = []
1067
1310
 
1068
1311
  for function_name, function_type, is_object_output in function_names_and_types:
1312
+ if function_name not in function_name_mapping:
1313
+ if is_peft_adapter:
1314
+ ModelOperator._raise_unresolved_adapter_pin_signatures()
1315
+ raise KeyError(function_name)
1069
1316
  target_method = function_name_mapping[function_name]
1070
1317
 
1071
1318
  is_partitioned = False
1072
1319
  if function_type == model_manifest_schema.ModelMethodFunctionTypes.TABLE_FUNCTION.value:
1073
1320
  # better to set default True here because worse case it will be slow but not error out
1074
- is_partitioned = parsed_model_spec.is_partitioned(target_method)
1321
+ is_partitioned = signature_spec.is_partitioned(target_method)
1075
1322
 
1076
1323
  model_func_info.append(
1077
1324
  model_manifest_schema.ModelFunctionInfo(
@@ -9,6 +9,13 @@ from snowflake.snowpark import row
9
9
  _EMPTY_VERSION_RESULT_EXPECTED = "Expected 1 rows"
10
10
  _EMPTY_VERSION_RESULT_FOUND = "found: 0 rows"
11
11
 
12
+ # Catalog propagation can take tens of seconds on a loaded account, so retries are bounded by elapsed
13
+ # wall-clock time in addition to attempt count.
14
+ SHOW_VERSIONS_RETRY_MAX_ATTEMPTS = 15
15
+ SHOW_VERSIONS_RETRY_MAX_DELAY_MS = 60000
16
+ SHOW_VERSIONS_RETRY_WAIT_MULTIPLIER_MS = 500
17
+ SHOW_VERSIONS_RETRY_WAIT_MAX_MS = 5000
18
+
12
19
 
13
20
  def _is_empty_version_result(exc: BaseException) -> bool:
14
21
  if not isinstance(exc, connector.DataError):
@@ -104,9 +111,10 @@ class ModelSQLClient(_base._BaseSQLClient):
104
111
 
105
112
  versions: list[row.Row] = retrying.retry(
106
113
  retry_on_exception=_is_empty_version_result,
107
- stop_max_attempt_number=5,
108
- wait_exponential_multiplier=100,
109
- wait_exponential_max=10000,
114
+ stop_max_attempt_number=SHOW_VERSIONS_RETRY_MAX_ATTEMPTS,
115
+ stop_max_delay=SHOW_VERSIONS_RETRY_MAX_DELAY_MS,
116
+ wait_exponential_multiplier=SHOW_VERSIONS_RETRY_WAIT_MULTIPLIER_MS,
117
+ wait_exponential_max=SHOW_VERSIONS_RETRY_WAIT_MAX_MS,
110
118
  )(_execute)()
111
119
  return versions
112
120
  return _execute()
@@ -38,6 +38,21 @@ if TYPE_CHECKING:
38
38
 
39
39
  logger = logging.getLogger(__name__)
40
40
 
41
+ # Retry once because another session can create the model between existence validation and model creation.
42
+ _MAX_LOG_MODEL_ATTEMPTS_FOR_CREATE_MODEL_RACE = 2
43
+ _OBJECT_ALREADY_EXISTS_SQL_ERROR_CODE = "100132"
44
+
45
+
46
+ def _is_create_model_object_already_exists_error(
47
+ exc: snowpark_exceptions.SnowparkSQLException,
48
+ ) -> bool:
49
+ """True when Snowflake reports object already exists from CREATE MODEL (concurrent creation race)."""
50
+ return (
51
+ str(exc.sql_error_code) == _OBJECT_ALREADY_EXISTS_SQL_ERROR_CODE
52
+ or _OBJECT_ALREADY_EXISTS_SQL_ERROR_CODE in exc.message
53
+ )
54
+
55
+
41
56
  # Query-level custom_tags so telemetry can distinguish log_model SQL paths.
42
57
  MODEL_LOG_PATH_TAG = "model_log_path"
43
58
  MODEL_LOG_PATH_LIVE_COMMIT = "live_commit"
@@ -312,6 +327,69 @@ class ModelManager:
312
327
  experiment_info: "ExperimentInfo | None" = None,
313
328
  options: type_hints.ModelSaveOption | None = None,
314
329
  statement_params: dict[str, Any] | None = None,
330
+ ) -> model_version_impl.ModelVersion:
331
+ for attempt_index in range(_MAX_LOG_MODEL_ATTEMPTS_FOR_CREATE_MODEL_RACE):
332
+ try:
333
+ return self._log_model_after_existence_checks(
334
+ model=model,
335
+ model_name=model_name,
336
+ version_name=version_name,
337
+ progress_status=progress_status,
338
+ comment=comment,
339
+ metrics=metrics,
340
+ conda_dependencies=conda_dependencies,
341
+ pip_requirements=pip_requirements,
342
+ artifact_repository_map=artifact_repository_map,
343
+ resource_constraint=resource_constraint,
344
+ target_platforms=target_platforms,
345
+ python_version=python_version,
346
+ signatures=signatures,
347
+ sample_input_data=sample_input_data,
348
+ user_files=user_files,
349
+ code_paths=code_paths,
350
+ ext_modules=ext_modules,
351
+ task=task,
352
+ experiment_info=experiment_info,
353
+ options=options,
354
+ statement_params=statement_params,
355
+ )
356
+ except snowpark_exceptions.SnowparkSQLException as exc:
357
+ if (
358
+ attempt_index + 1 < _MAX_LOG_MODEL_ATTEMPTS_FOR_CREATE_MODEL_RACE
359
+ and _is_create_model_object_already_exists_error(exc)
360
+ ):
361
+ logger.info(
362
+ "Model creation encountered a concurrent creation; retrying: %s",
363
+ exc,
364
+ )
365
+ continue
366
+ raise
367
+ raise AssertionError("Model logging reached an unreachable state after exhausting retries.") # pragma: no cover
368
+
369
+ def _log_model_after_existence_checks(
370
+ self,
371
+ *,
372
+ model: type_hints.SupportedModelType | model_version_impl.ModelVersion,
373
+ model_name: str,
374
+ version_name: str | None,
375
+ progress_status: type_hints.ProgressStatus,
376
+ comment: str | None = None,
377
+ metrics: dict[str, Any] | None = None,
378
+ conda_dependencies: list[str] | None = None,
379
+ pip_requirements: list[str] | None = None,
380
+ artifact_repository_map: dict[str, str] | None = None,
381
+ resource_constraint: dict[str, str] | None = None,
382
+ target_platforms: list[type_hints.SupportedTargetPlatformType] | None = None,
383
+ python_version: str | None = None,
384
+ signatures: dict[str, model_signature.ModelSignature] | None = None,
385
+ sample_input_data: type_hints.SupportedDataType | None = None,
386
+ user_files: dict[str, list[str]] | None = None,
387
+ code_paths: list[type_hints.CodePathLike] | None = None,
388
+ ext_modules: list[ModuleType] | None = None,
389
+ task: type_hints.Task = task.Task.UNKNOWN,
390
+ experiment_info: "ExperimentInfo | None" = None,
391
+ options: type_hints.ModelSaveOption | None = None,
392
+ statement_params: dict[str, Any] | None = None,
315
393
  ) -> model_version_impl.ModelVersion:
316
394
  database_name_id, schema_name_id, model_name_id = self._parse_fully_qualified_name(model_name)
317
395
 
snowflake/ml/version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  # This is parsed by regex in conda recipe meta file. Make sure not to break it.
2
- VERSION = "2.2.0"
2
+ VERSION = "2.3.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-ml-python
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: The machine learning client library that is used for interacting with Snowflake to build machine learning solutions.
5
5
  Author-email: "Snowflake, Inc" <support@snowflake.com>
6
6
  License:
@@ -424,7 +424,25 @@ NOTE: Version 1.7.0 is used as example here. Please choose the the latest versio
424
424
 
425
425
  # Release History
426
426
 
427
- ## 2.2.0
427
+ ## 2.3.0
428
+
429
+ ### New Features
430
+
431
+ * Experiment Tracking: `ExperimentTracking` accepts an optional `model_registry` parameter. When provided,
432
+ `log_model` stores models in the registry's database and schema instead of the experiment's, so experiments
433
+ and models can live in separate schemas.
434
+
435
+ ### Bug Fixes
436
+
437
+ * Feature Store: Offline reads of a feature view with several list aggregations or several secondary-key
438
+ aggregation windows used to return incomplete results in some scenarios. The bug has been fixed
439
+ and the existing feature views pick up the fix on their next read, with no re-registration or backfill required.
440
+
441
+ ### Behavior Changes
442
+
443
+ ### Deprecations
444
+
445
+ ## 2.2.0 (2026-09-21)
428
446
 
429
447
  ### New Features
430
448
 
@@ -438,8 +456,17 @@ NOTE: Version 1.7.0 is used as example here. Please choose the the latest versio
438
456
 
439
457
  ### Bug Fixes
440
458
 
459
+ * Registry: `ModelVersion.load()` now compares the model owner to `CURRENT_ROLE()` (the role that
460
+ executes SQL) instead of `Session.get_current_role()`. Owner's-rights Streamlit apps and
461
+ `EXECUTE AS OWNER` procedures can load when statements run as the owner even if the caller's
462
+ primary role is not the owner.
463
+
441
464
  ### Behavior Changes
442
465
 
466
+ * Dependencies: `scipy` is no longer a required dependency of `snowflake-ml-python`. It is still
467
+ installed transitively with `pip install snowflake-ml-python[scikit-learn]`. Direct `scipy` usage
468
+ in `snowflake.ml.modeling.preprocessing` already requires that extra.
469
+
443
470
  ### Deprecations
444
471
 
445
472
  ## 2.1.0 (2026-09-14)
@@ -461,21 +488,12 @@ NOTE: Version 1.7.0 is used as example here. Please choose the the latest versio
461
488
 
462
489
  ### Bug Fixes
463
490
 
464
- * Registry: `ModelVersion.load()` now compares the model owner to `CURRENT_ROLE()` (the role that
465
- executes SQL) instead of `Session.get_current_role()`. Owner's-rights Streamlit apps and
466
- `EXECUTE AS OWNER` procedures can load when statements run as the owner even if the caller's
467
- primary role is not the owner.
468
-
469
491
  * Registry: Fixed logging of MLflow models created with `mlflow.sklearn.save_model()`. The model is
470
492
  now re-logged from the scikit-learn estimator using the serialization format it was saved with,
471
493
  instead of from MLflow's PyFunc wrapper with the version-dependent default format.
472
494
 
473
495
  ### Behavior Changes
474
496
 
475
- * Dependencies: `scipy` is no longer a required dependency of `snowflake-ml-python`. It is still
476
- installed transitively with `pip install snowflake-ml-python[scikit-learn]`. Direct `scipy` usage
477
- in `snowflake.ml.modeling.preprocessing` already requires that extra.
478
-
479
497
  ### Deprecations
480
498
 
481
499
  ## 2.0.0 (2026-09-09)
@@ -10,7 +10,7 @@ snowflake/cortex/_sse_client.py,sha256=pYsVOmU795LnGQnRE03ChGwQ17nKp7xVHeTpcToTo
10
10
  snowflake/cortex/_summarize.py,sha256=yvD-hxiqgB57eMwJDjrz437s0RfeQcG9r8PcG3R0Kuk,1267
11
11
  snowflake/cortex/_translate.py,sha256=yN09msgV1HMDk7ibDIrC1hux_LUJakY1KeYh7Z9yEPE,1588
12
12
  snowflake/cortex/_util.py,sha256=7CW-ISSp1FEOH_gQD0DouwqOJtiJLOHaFKTED33F3YE,3228
13
- snowflake/ml/version.py,sha256=Yv0GitnZjxJ6QMMFKiVgCOflrt_kMU5YW3nWiTjE9so,98
13
+ snowflake/ml/version.py,sha256=PxEBviRkj5USpBkrj7P95vXbwxhkWgi2v4nu6LGrjcY,98
14
14
  snowflake/ml/_internal/env.py,sha256=EY_2KVe8oR3LgKWdaeRb5rRU-NDNXJppPDsFJmMZUUY,265
15
15
  snowflake/ml/_internal/env_utils.py,sha256=qmvqXgfdaxX25k_k4Jr5QWc5wuoyuI-s12ynZBjaRDI,34174
16
16
  snowflake/ml/_internal/file_utils.py,sha256=XQnRrjkS61e0He4mz2Ist-MwgLd4Rnodw_i6jEwszxM,15316
@@ -68,7 +68,7 @@ snowflake/ml/dataset/dataset_reader.py,sha256=5HI1ivKY6RO50yOdacI6aYe9U8zYq4h-Ca
68
68
  snowflake/ml/experiment/__init__.py,sha256=r7qdyPd3jwxzqvksim2ju5j_LrnYQrta0ZI6XpWUqmc,109
69
69
  snowflake/ml/experiment/_experiment_info.py,sha256=uPcso2A4ZUuR4jsXFjW64ztwQ1fVj3C3oHFqwz82hYY,2573
70
70
  snowflake/ml/experiment/_source_info.py,sha256=fvdk_bWbQn9Bv3T6vh4ZH77mntL1TfUXJW6GGp7eRWg,20115
71
- snowflake/ml/experiment/experiment_tracking.py,sha256=CLK5Bv2pPnKAwO386vPjm8Ft8Qf1rpTkghFtyQxnIr4,38538
71
+ snowflake/ml/experiment/experiment_tracking.py,sha256=aBKxlIvSkTo41MvuHzfPEnuSMy7cCN1aAs67hqxmDcY,39534
72
72
  snowflake/ml/experiment/utils.py,sha256=zAxH1t-NcVbYeIt1Sfq2vX4dH4ozknQ7VClushnkHzE,817
73
73
  snowflake/ml/experiment/_client/artifact.py,sha256=R2WB4Y_kqv43BWLfXv8SEDINn1Bnevzgb-mH5LyvgGk,3035
74
74
  snowflake/ml/experiment/_client/experiment_tracking_sql_client.py,sha256=mqHJ35ovBeXs8stMbZgtS_INs8zEe42uCB3sNGeRFbk,14925
@@ -105,7 +105,7 @@ snowflake/ml/feature_store/request_source.py,sha256=ggof9GXZT6fCvbPT6c_LyJUXT7tL
105
105
  snowflake/ml/feature_store/stream_config.py,sha256=2Mns0CIOzPKeDrrtJoj7pufjMTwkx8fgTDbs4arxUbc,12325
106
106
  snowflake/ml/feature_store/stream_source.py,sha256=JOz60TIzyWlCHs6ITqaP8Hnd8YOA8i6N4hdC9lu-22Q,9493
107
107
  snowflake/ml/feature_store/streaming_registration.py,sha256=tAn7Vsbk2eMUNgAEGjREskzj-3U4HuKmoGlTebA5J8E,60727
108
- snowflake/ml/feature_store/tile_sql_generator.py,sha256=wHe0YuzZ6T9RaPaPqFOagMHsbk_d5JlWLR0637yjx30,100791
108
+ snowflake/ml/feature_store/tile_sql_generator.py,sha256=03BOgvwGsLUj3bfLurwYvQYa4K6IrjzfsNA8jCGaqLQ,101656
109
109
  snowflake/ml/feature_store/examples/example_helper.py,sha256=2TA5d6oJlerUjR1kz3M6bWKCmOxznSoQcghiJZJLIvE,12448
110
110
  snowflake/ml/feature_store/examples/airline_features/entities.py,sha256=mzHRS-InHpXON0eHds-QLmi7nK9ciOnCruhPZI4niLs,438
111
111
  snowflake/ml/feature_store/examples/airline_features/source.yaml,sha256=kzl8ukOK8OuSPsxChEgJ9SPyPnzC-fPHqZC4O6aqd5o,247
@@ -140,7 +140,7 @@ snowflake/ml/fileset/snowfs.py,sha256=3SHuX43bokhd5Z7T333j0mByZ-4JbeyBk5fHLKIOiG
140
140
  snowflake/ml/fileset/stage_fs.py,sha256=0jdWkzw-Ovn5JIlxv-Eb2LU9YI-yKvrkE2ZVPty8GFY,20647
141
141
  snowflake/ml/jobs/__init__.py,sha256=EkEvFjhZpwLD84kcO0oYCA9rL-UQmU8wTuNXecreIG8,884
142
142
  snowflake/ml/jobs/decorators.py,sha256=O0Y4u5yS40L4CGD5H0N5xU5E403OTi2XtGnQi5XbXT0,4018
143
- snowflake/ml/jobs/job.py,sha256=KBfE7qIezp4I-w9vM1MApTp0ha3z_XM0KCPhdpei8bo,44933
143
+ snowflake/ml/jobs/job.py,sha256=qAeE39XMtWh5neaodPVuxIMPsG2-vCeqwthpbc1jChk,45835
144
144
  snowflake/ml/jobs/job_definition.py,sha256=O4PZAFtDKtpLJ0Zt-e-FLCBSwDC0L3xs1UoNYX-obzU,27392
145
145
  snowflake/ml/jobs/manager.py,sha256=XjCvTyL4RYNdSa93iZ5761C7jfOQvWf-gmJdwhwamSg,24595
146
146
  snowflake/ml/jobs/_interop/__init__.py,sha256=RGilEHNPLoq8lu__pq-iHUkRx3A4xv3GRd_aBPXgdWQ,266
@@ -197,7 +197,7 @@ snowflake/ml/model/_client/model_spec/model_spec_parser.py,sha256=yJFsL2fFlvJ9gb
197
197
  snowflake/ml/model/_client/ops/deployment_step.py,sha256=OATDctajCjBkKnZiU3LvSZMh31WxXzCsDB_nf1DgFRQ,1394
198
198
  snowflake/ml/model/_client/ops/live_commit_naming.py,sha256=6G8nuMmVDrEl0HH4LqsWT5EB3JaUcaTD9fFOW34rmvg,647
199
199
  snowflake/ml/model/_client/ops/metadata_ops.py,sha256=aT3QWIeg-z4tM_Iniegkvr7SycvSQ5cfU0jXWR7N4rI,4847
200
- snowflake/ml/model/_client/ops/model_ops.py,sha256=jA6ujYB-VpT8g6WlXe0lHP-ZufwrCxHZp0aluWsLZK0,60018
200
+ snowflake/ml/model/_client/ops/model_ops.py,sha256=monHS0AV148shsUI5JriW5n_v30-yFtnyXHitTRGE64,69920
201
201
  snowflake/ml/model/_client/ops/param_ops.py,sha256=AA7NfjUsYSv5hzCMrDMrLyRi_ueIKGktW1hBaBtsSgA,19966
202
202
  snowflake/ml/model/_client/ops/param_utils.py,sha256=WWLGLfXyblbZXsh9ulzr23fb2zoWcLOQPYBTWkKi3DQ,5374
203
203
  snowflake/ml/model/_client/ops/service_ops.py,sha256=5lz_N1APqHoFzfgz5lNqE6YEhKzGSnxxP_b0z7kzwbQ,63887
@@ -206,7 +206,7 @@ snowflake/ml/model/_client/service/inference_job_service_spec.py,sha256=Q23vAQkI
206
206
  snowflake/ml/model/_client/service/model_deployment_spec.py,sha256=7jhVxN8_AF6z2gKfb2iqPJ-_O4c6WPpuVbfAUPiBts8,19567
207
207
  snowflake/ml/model/_client/service/model_deployment_spec_schema.py,sha256=Gu3iH_zUEo8nsu8f6Cwb2sEhc_G6dBINKtY4hqXzsGM,3034
208
208
  snowflake/ml/model/_client/sql/_base.py,sha256=y4u3nmvruHGFXQ5hz9ANGmvduTKUH3M5GF69_v6_Q54,1438
209
- snowflake/ml/model/_client/sql/model.py,sha256=vcZY6WIgt7mem3TxLe06z0QbcdCC1S0r8xz4VLL8hiA,6871
209
+ snowflake/ml/model/_client/sql/model.py,sha256=1RO37kFwFDiGwPEKSWbcJD0mglx3hG_BP75hR7cZOuc,7342
210
210
  snowflake/ml/model/_client/sql/model_version.py,sha256=xXPm7X5Y2VgfaVVDbH341rCkEcuGmWmUG2WDsnA4wH4,26441
211
211
  snowflake/ml/model/_client/sql/service.py,sha256=hFGVJqWHWZU6f-N8DmmPWFFHlYS9nI3wMsZcP_mRHcM,21484
212
212
  snowflake/ml/model/_client/sql/stage.py,sha256=W1SNeS-q4ZpSkG613Ajad4TB6DdFdwgnGMusQacH5H0,1195
@@ -534,15 +534,15 @@ snowflake/ml/monitoring/_manager/model_monitor_manager.py,sha256=OX99bu6W7ptHTXk
534
534
  snowflake/ml/monitoring/entities/model_monitor_config.py,sha256=sFueQ1_w9YogbFfu-K4Hdb0WFgZev3qiwR5xrRB6ACo,2219
535
535
  snowflake/ml/registry/__init__.py,sha256=XdPQK9ejYkSJVrSQ7HD3jKQO0hKq2mC4bPCB6qrtH3U,76
536
536
  snowflake/ml/registry/registry.py,sha256=ei9v-8-bAOEVydrafCdn4cIBVtIG-fpF2WTc4aesNJQ,37852
537
- snowflake/ml/registry/_manager/model_manager.py,sha256=E7pFTDaEWRFkWsDrQszm90DwSQCVWGvudahtDmNczQ8,41030
537
+ snowflake/ml/registry/_manager/model_manager.py,sha256=NiZAurDE29jC5go5J_SQfbrfb2EI47bPZrKua5Q2BIk,44701
538
538
  snowflake/ml/registry/_manager/model_parameter_reconciler.py,sha256=7MEkXzhI4iSArW3lf5XyI6J0sd6twGAcdf2Hjn9qs4E,19937
539
539
  snowflake/ml/utils/authentication.py,sha256=UQeGgGYJkaFJf-EDohPlSWl0sAsGzMxv5d2wduikfAw,3082
540
540
  snowflake/ml/utils/html_utils.py,sha256=_jywZlZiy_oDx8dardkyoeTFRkqfTC8ZoFfz75o7ZE4,9870
541
541
  snowflake/ml/utils/sparse.py,sha256=nIoSfH0roCQ3QtaIltinDLqV4coCIw2pc2qT_FsFqEE,4116
542
542
  snowflake/ml/utils/sql_client.py,sha256=pSe2od6Pkh-8NwG3D-xqN76_uNf-ohOtVbT55HeQg1Y,668
543
543
  snowflake/ml/utils/stage_file.py,sha256=FHfFUPF1YG_l7iBYjCiUTVCL4-W5LFZE5mo9MuoD0E8,7910
544
- snowflake_ml_python-2.2.0.dist-info/licenses/LICENSE.txt,sha256=PdEp56Av5m3_kl21iFkVTX_EbHJKFGEdmYeIO1pL_Yk,11365
545
- snowflake_ml_python-2.2.0.dist-info/METADATA,sha256=OFw5iHYGKQCU55DTQYoAIhXlQauSYWddGWSbWrBjong,147429
546
- snowflake_ml_python-2.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
547
- snowflake_ml_python-2.2.0.dist-info/top_level.txt,sha256=TY0gFSHKDdZy3THb0FGomyikWQasEGldIR1O0HGOHVw,10
548
- snowflake_ml_python-2.2.0.dist-info/RECORD,,
544
+ snowflake_ml_python-2.3.0.dist-info/licenses/LICENSE.txt,sha256=PdEp56Av5m3_kl21iFkVTX_EbHJKFGEdmYeIO1pL_Yk,11365
545
+ snowflake_ml_python-2.3.0.dist-info/METADATA,sha256=iaZjrQJcr6-hZKIpeVNlDAkhAxILmw96V1Fl9OtosMY,148105
546
+ snowflake_ml_python-2.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
547
+ snowflake_ml_python-2.3.0.dist-info/top_level.txt,sha256=TY0gFSHKDdZy3THb0FGomyikWQasEGldIR1O0HGOHVw,10
548
+ snowflake_ml_python-2.3.0.dist-info/RECORD,,