gitlabds 2.4.3__tar.gz → 2.4.4__tar.gz

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.
Files changed (34) hide show
  1. {gitlabds-2.4.3 → gitlabds-2.4.4}/PKG-INFO +22 -10
  2. {gitlabds-2.4.3 → gitlabds-2.4.4}/README.md +21 -9
  3. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/insights.py +14 -2
  4. gitlabds-2.4.4/gitlabds/serving_features.py +484 -0
  5. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds.egg-info/PKG-INFO +22 -10
  6. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds.egg-info/SOURCES.txt +1 -0
  7. gitlabds-2.4.4/tests/test_serving_features.py +162 -0
  8. gitlabds-2.4.3/gitlabds/serving_features.py +0 -430
  9. {gitlabds-2.4.3 → gitlabds-2.4.4}/LICENSE +0 -0
  10. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/__init__.py +0 -0
  11. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/baselines.py +0 -0
  12. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/config_generator.py +0 -0
  13. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/dummy.py +0 -0
  14. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/feature_descriptions.py +0 -0
  15. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/feature_reduction.py +0 -0
  16. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/memory_optimization.py +0 -0
  17. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/missing.py +0 -0
  18. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/model_evaluator.py +0 -0
  19. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/monitoring_metrics.py +0 -0
  20. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/outliers.py +0 -0
  21. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/record_benchmarking.py +0 -0
  22. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/snowflake_connectors.py +0 -0
  23. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/split_data.py +0 -0
  24. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds/trends.py +0 -0
  25. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds.egg-info/dependency_links.txt +0 -0
  26. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds.egg-info/requires.txt +0 -0
  27. {gitlabds-2.4.3 → gitlabds-2.4.4}/gitlabds.egg-info/top_level.txt +0 -0
  28. {gitlabds-2.4.3 → gitlabds-2.4.4}/pyproject.toml +0 -0
  29. {gitlabds-2.4.3 → gitlabds-2.4.4}/setup.cfg +0 -0
  30. {gitlabds-2.4.3 → gitlabds-2.4.4}/setup.py +0 -0
  31. {gitlabds-2.4.3 → gitlabds-2.4.4}/tests/test_feature_reduction.py +0 -0
  32. {gitlabds-2.4.3 → gitlabds-2.4.4}/tests/test_record_benchmarking.py +0 -0
  33. {gitlabds-2.4.3 → gitlabds-2.4.4}/tests/test_snowflake_connectors.py +0 -0
  34. {gitlabds-2.4.3 → gitlabds-2.4.4}/tests/test_split_data.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabds
3
- Version: 2.4.3
3
+ Version: 2.4.4
4
4
  Summary: GitLab Data Science Tools
5
5
  Author-email: Kevin Dietz <kdietz@gitlab.com>
6
6
  License-Expression: MIT
@@ -790,7 +790,13 @@ display(effects_df)
790
790
  ```
791
791
  </details>
792
792
 
793
- <details><summary> Prescriptions </summary>
793
+ <details><summary> Prescriptions (deprecated) </summary>
794
+
795
+ > **Deprecated:** `prescriptions` raises a `DeprecationWarning` and will be removed in a future
796
+ > release. Use `per_record_shap` and `per_record_counterfactuals` instead. Note that
797
+ > `per_record_shap` requires a tree-based model (e.g. LightGBM, XGBoost), so they are not a
798
+ > drop-in replacement for linear/logistic models. They also choose each move's direction from
799
+ > the model and size moves from peer-group quantiles rather than a fixed step.
794
800
 
795
801
  #### Description
796
802
  Return "actionable" prescriptions and explanatory insights for each scored record. Insights first list actionable prescriptions follow by explainatory insights. This approach is recommended or linear/logistic methodologies only. Caution should be used if using a black box approach, as manpulating more than one prescription at a time could change a record's model score in unintended ways.
@@ -1391,24 +1397,30 @@ Also worth knowing:
1391
1397
  <details><summary> Serve Features </summary>
1392
1398
 
1393
1399
  #### Description
1394
- Serve features from Snowflake Feature Store with built-in distributed locking and retry logic. This function orchestrates feature retrieval from multiple feature views with support for point-in-time lookups and flexible parameter customization.
1400
+ Serve features from Snowflake Feature Store with per-call UDF parameters and retry logic. Each call shadows the shared `{FV}_DYNAMIC` view with a `CREATE OR REPLACE TEMPORARY VIEW` of the same name, so the parameters apply only to your Snowpark session: callers on other sessions (including your own other notebooks) never see them, and the permanent view keeps its UDF defaults.
1401
+
1402
+ - **Schema:** view and UDF names are qualified with the feature view's database and schema, so a `USE SCHEMA` in your session doesn't change what is served.
1403
+ - **Redeploying in the same session:** the temporary views last until the session closes, so a session that serves and then redeploys the permanent `_DYNAMIC` view keeps reading its temporary view until the next `serve_features` call. Use a fresh session to read the redeployed defaults.
1404
+ - **Concurrency:** calls sharing one session object run one at a time. Open one session per thread for real parallelism.
1405
+ - **Feature-date check:** when `feature_date` is set, the served date is verified and a mismatch raises `ValueError`. With `spine_timestamp_col` and `include_feature_view_timestamp_col=True` the returned `{FV}_{version}_FEATURE_DATE` column is checked; otherwise the session's temporary view is queried before retrieval, which may run the whole UDF.
1406
+ - **Spine timestamps earlier than `feature_date`** match no feature rows in the point-in-time join and return all-null features. A warning is raised when this happens.
1395
1407
 
1396
- `gitlabds.serve_features(session, feature_store, feature_views_dict, spine_df=None, feature_date=None, spine_timestamp_col=None, include_feature_view_timestamp_col=False, lookback_window_value=None, lookback_window_unit=None, lock_timeout_seconds=300, max_fetch_retries=3, fetch_retry_backoff_seconds=30):`
1408
+ `gitlabds.serve_features(session, feature_store, feature_views_dict, spine_df=None, feature_date=None, spine_timestamp_col=None, include_feature_view_timestamp_col=False, lookback_window_value=None, lookback_window_unit=None, lock_timeout_seconds=None, max_fetch_retries=3, fetch_retry_backoff_seconds=30):`
1397
1409
 
1398
1410
  #### Parameters:
1399
- - **_session_** : Active Snowflake Session object
1411
+ - **_session_** : Active Snowflake Session object. Must be the session the FeatureStore was built with (`feature_store._session`); a different session raises `ValueError`, because retrieval could not see this call's temporary views.
1400
1412
  - **_feature_store_** : Initialized FeatureStore instance
1401
1413
  - **_feature_views_dict_** : Dictionary mapping feature view names to versions (e.g., {"sales_activities": "1.0"})
1402
1414
  - **_spine_df_** : One of the following:
1403
1415
  - SQL query string to generate the spine DataFrame
1404
1416
  - pandas DataFrame (will be converted to Snowpark DataFrame)
1405
1417
  - Snowpark DataFrame (used directly)
1406
- - **_feature_date_** : Date for feature retrieval (e.g., '2025-04-08'). If None, uses UDF default.
1418
+ - **_feature_date_** : Date for feature retrieval (e.g., '2025-04-08'). If None, uses UDF default and skips the feature-date check.
1407
1419
  - **_spine_timestamp_col_** : Name of timestamp column in spine DataFrame for point-in-time lookup. Used for when multiple feature_dates may be present in the feature view. If only one feature_date is present, can set value to None for faster execution.
1408
- - **_include_feature_view_timestamp_col_** : Whether to include timestamp column from feature views in output. Default is False.
1420
+ - **_include_feature_view_timestamp_col_** : Whether to include timestamp column from feature views in output. Only takes effect with `spine_timestamp_col`. Default is False.
1409
1421
  - **_lookback_window_value_** : Either a global integer value (e.g., 6) or a dict with feature-view-specific values
1410
1422
  - **_lookback_window_unit_** : Either a global string value (e.g., 'months') or a dict with feature-view-specific values
1411
- - **_lock_timeout_seconds_** : Timeout in seconds for acquiring the Snowflake lock. Default is 300.
1423
+ - **_lock_timeout_seconds_** : Deprecated and ignored; the Snowflake lock table is no longer used. Passing it raises a `DeprecationWarning`.
1412
1424
  - **_max_fetch_retries_** : Maximum number of attempts for the result-fetch step (`retrieve_feature_values` + `to_arrow().to_pandas()`) when a transient network/streaming error occurs (e.g., `ChunkedEncodingError`, "Response ended prematurely"). Set to 1 to disable retries. Default is 3.
1413
1425
  - **_fetch_retry_backoff_seconds_** : Base sleep duration between fetch retries. Sleep grows linearly with attempt number (`attempt * backoff`). With defaults (3 retries, 30s base), sleeps are 30s before attempt 2 and 60s before attempt 3. Default is 30.
1414
1426
 
@@ -1468,10 +1480,10 @@ features = gitlabds.serve_features(
1468
1480
 
1469
1481
  </details>
1470
1482
 
1471
- <details><summary> Clear Feature Serving Locks </summary>
1483
+ <details><summary> Clear Feature Serving Locks (deprecated) </summary>
1472
1484
 
1473
1485
  #### Description
1474
- Utility function to manually clear all feature serving locks. Use this if locks get stuck due to killed processes.
1486
+ **Deprecated.** `serve_features` no longer uses the `FEATURE_SERVING_LOCKS` table, so there is nothing to get stuck. This clears any leftover rows from that table, raises a `DeprecationWarning`, and will be removed in a future release. The table can be dropped.
1475
1487
 
1476
1488
  `gitlabds.clear_feature_serving_locks(session):`
1477
1489
 
@@ -754,7 +754,13 @@ display(effects_df)
754
754
  ```
755
755
  </details>
756
756
 
757
- <details><summary> Prescriptions </summary>
757
+ <details><summary> Prescriptions (deprecated) </summary>
758
+
759
+ > **Deprecated:** `prescriptions` raises a `DeprecationWarning` and will be removed in a future
760
+ > release. Use `per_record_shap` and `per_record_counterfactuals` instead. Note that
761
+ > `per_record_shap` requires a tree-based model (e.g. LightGBM, XGBoost), so they are not a
762
+ > drop-in replacement for linear/logistic models. They also choose each move's direction from
763
+ > the model and size moves from peer-group quantiles rather than a fixed step.
758
764
 
759
765
  #### Description
760
766
  Return "actionable" prescriptions and explanatory insights for each scored record. Insights first list actionable prescriptions follow by explainatory insights. This approach is recommended or linear/logistic methodologies only. Caution should be used if using a black box approach, as manpulating more than one prescription at a time could change a record's model score in unintended ways.
@@ -1355,24 +1361,30 @@ Also worth knowing:
1355
1361
  <details><summary> Serve Features </summary>
1356
1362
 
1357
1363
  #### Description
1358
- Serve features from Snowflake Feature Store with built-in distributed locking and retry logic. This function orchestrates feature retrieval from multiple feature views with support for point-in-time lookups and flexible parameter customization.
1364
+ Serve features from Snowflake Feature Store with per-call UDF parameters and retry logic. Each call shadows the shared `{FV}_DYNAMIC` view with a `CREATE OR REPLACE TEMPORARY VIEW` of the same name, so the parameters apply only to your Snowpark session: callers on other sessions (including your own other notebooks) never see them, and the permanent view keeps its UDF defaults.
1365
+
1366
+ - **Schema:** view and UDF names are qualified with the feature view's database and schema, so a `USE SCHEMA` in your session doesn't change what is served.
1367
+ - **Redeploying in the same session:** the temporary views last until the session closes, so a session that serves and then redeploys the permanent `_DYNAMIC` view keeps reading its temporary view until the next `serve_features` call. Use a fresh session to read the redeployed defaults.
1368
+ - **Concurrency:** calls sharing one session object run one at a time. Open one session per thread for real parallelism.
1369
+ - **Feature-date check:** when `feature_date` is set, the served date is verified and a mismatch raises `ValueError`. With `spine_timestamp_col` and `include_feature_view_timestamp_col=True` the returned `{FV}_{version}_FEATURE_DATE` column is checked; otherwise the session's temporary view is queried before retrieval, which may run the whole UDF.
1370
+ - **Spine timestamps earlier than `feature_date`** match no feature rows in the point-in-time join and return all-null features. A warning is raised when this happens.
1359
1371
 
1360
- `gitlabds.serve_features(session, feature_store, feature_views_dict, spine_df=None, feature_date=None, spine_timestamp_col=None, include_feature_view_timestamp_col=False, lookback_window_value=None, lookback_window_unit=None, lock_timeout_seconds=300, max_fetch_retries=3, fetch_retry_backoff_seconds=30):`
1372
+ `gitlabds.serve_features(session, feature_store, feature_views_dict, spine_df=None, feature_date=None, spine_timestamp_col=None, include_feature_view_timestamp_col=False, lookback_window_value=None, lookback_window_unit=None, lock_timeout_seconds=None, max_fetch_retries=3, fetch_retry_backoff_seconds=30):`
1361
1373
 
1362
1374
  #### Parameters:
1363
- - **_session_** : Active Snowflake Session object
1375
+ - **_session_** : Active Snowflake Session object. Must be the session the FeatureStore was built with (`feature_store._session`); a different session raises `ValueError`, because retrieval could not see this call's temporary views.
1364
1376
  - **_feature_store_** : Initialized FeatureStore instance
1365
1377
  - **_feature_views_dict_** : Dictionary mapping feature view names to versions (e.g., {"sales_activities": "1.0"})
1366
1378
  - **_spine_df_** : One of the following:
1367
1379
  - SQL query string to generate the spine DataFrame
1368
1380
  - pandas DataFrame (will be converted to Snowpark DataFrame)
1369
1381
  - Snowpark DataFrame (used directly)
1370
- - **_feature_date_** : Date for feature retrieval (e.g., '2025-04-08'). If None, uses UDF default.
1382
+ - **_feature_date_** : Date for feature retrieval (e.g., '2025-04-08'). If None, uses UDF default and skips the feature-date check.
1371
1383
  - **_spine_timestamp_col_** : Name of timestamp column in spine DataFrame for point-in-time lookup. Used for when multiple feature_dates may be present in the feature view. If only one feature_date is present, can set value to None for faster execution.
1372
- - **_include_feature_view_timestamp_col_** : Whether to include timestamp column from feature views in output. Default is False.
1384
+ - **_include_feature_view_timestamp_col_** : Whether to include timestamp column from feature views in output. Only takes effect with `spine_timestamp_col`. Default is False.
1373
1385
  - **_lookback_window_value_** : Either a global integer value (e.g., 6) or a dict with feature-view-specific values
1374
1386
  - **_lookback_window_unit_** : Either a global string value (e.g., 'months') or a dict with feature-view-specific values
1375
- - **_lock_timeout_seconds_** : Timeout in seconds for acquiring the Snowflake lock. Default is 300.
1387
+ - **_lock_timeout_seconds_** : Deprecated and ignored; the Snowflake lock table is no longer used. Passing it raises a `DeprecationWarning`.
1376
1388
  - **_max_fetch_retries_** : Maximum number of attempts for the result-fetch step (`retrieve_feature_values` + `to_arrow().to_pandas()`) when a transient network/streaming error occurs (e.g., `ChunkedEncodingError`, "Response ended prematurely"). Set to 1 to disable retries. Default is 3.
1377
1389
  - **_fetch_retry_backoff_seconds_** : Base sleep duration between fetch retries. Sleep grows linearly with attempt number (`attempt * backoff`). With defaults (3 retries, 30s base), sleeps are 30s before attempt 2 and 60s before attempt 3. Default is 30.
1378
1390
 
@@ -1432,10 +1444,10 @@ features = gitlabds.serve_features(
1432
1444
 
1433
1445
  </details>
1434
1446
 
1435
- <details><summary> Clear Feature Serving Locks </summary>
1447
+ <details><summary> Clear Feature Serving Locks (deprecated) </summary>
1436
1448
 
1437
1449
  #### Description
1438
- Utility function to manually clear all feature serving locks. Use this if locks get stuck due to killed processes.
1450
+ **Deprecated.** `serve_features` no longer uses the `FEATURE_SERVING_LOCKS` table, so there is nothing to get stuck. This clears any leftover rows from that table, raises a `DeprecationWarning`, and will be removed in a future release. The table can be dropped.
1439
1451
 
1440
1452
  `gitlabds.clear_feature_serving_locks(session):`
1441
1453
 
@@ -1,3 +1,5 @@
1
+ import warnings
2
+
1
3
  import pandas as pd
2
4
  import numpy as np
3
5
 
@@ -283,8 +285,18 @@ def prescriptions(model, input_df:pd.DataFrame, scored_df:pd.DataFrame, actionab
283
285
  explanation_fields : List of explainable (non-actionable insights) fields to return insights for. Defaults to 'all' for all fields.
284
286
 
285
287
 
286
- Returns a Dataframe of prescriptive actions. One row per record input."""
287
-
288
+ Returns a Dataframe of prescriptive actions. One row per record input.
289
+
290
+ Deprecated: use per_record_shap and per_record_counterfactuals instead. prescriptions will
291
+ be removed in a future release."""
292
+
293
+ warnings.warn(
294
+ "gitlabds.prescriptions is deprecated and will be removed in a future release. "
295
+ "Use gitlabds.per_record_shap and gitlabds.per_record_counterfactuals instead.",
296
+ DeprecationWarning,
297
+ stacklevel=2,
298
+ )
299
+
288
300
  bf = pd.DataFrame(input_df.columns.tolist(), columns=['feature'])
289
301
 
290
302
  change_score = pd.DataFrame()
@@ -0,0 +1,484 @@
1
+ """
2
+ Feature serving functionality for Snowflake Feature Store integration.
3
+
4
+ This module serves features from Snowflake Feature Store. Each call applies its parameters
5
+ through session-scoped temporary views, so callers on different Snowpark sessions never see
6
+ each other's parameters, and calls sharing one session are serialized in-process.
7
+
8
+ Requires the optional Snowflake dependencies: pip install gitlabds[snowflake]
9
+ """
10
+
11
+ import datetime
12
+ import threading
13
+ import time
14
+ import uuid
15
+ import warnings
16
+ import weakref
17
+ from typing import Any, Dict, List, Optional, Union
18
+
19
+ import pandas as pd
20
+ from snowflake.snowpark import Session, DataFrame as SnowparkDataFrame
21
+ from snowflake.ml.feature_store import FeatureStore
22
+
23
+
24
+ _SESSION_LOCKS: "weakref.WeakKeyDictionary[Session, threading.Lock]" = weakref.WeakKeyDictionary()
25
+ _SESSION_LOCKS_GUARD = threading.Lock()
26
+
27
+
28
+ # Transient network/streaming errors that warrant a retry of the result fetch.
29
+ # These are the errors that surface when the HTTP response from Snowflake is
30
+ # interrupted mid-stream (e.g., flaky network, VPN blip, load-balancer reset).
31
+ def _is_transient_fetch_error(exc: BaseException) -> bool:
32
+ """Return True if the exception looks like a transient streaming/network failure."""
33
+ # Match by class name to avoid hard imports of requests / urllib3 at module load.
34
+ transient_class_names = {
35
+ "ChunkedEncodingError",
36
+ "ProtocolError",
37
+ "ConnectionError",
38
+ "ConnectionResetError",
39
+ "ReadTimeoutError",
40
+ "ReadTimeout",
41
+ "IncompleteRead",
42
+ }
43
+ # Walk the exception chain — Snowpark often wraps the original error.
44
+ seen = set()
45
+ current = exc
46
+ while current is not None and id(current) not in seen:
47
+ seen.add(id(current))
48
+ if type(current).__name__ in transient_class_names:
49
+ return True
50
+ msg = str(current).lower()
51
+ if "response ended prematurely" in msg:
52
+ return True
53
+ if "connection reset" in msg:
54
+ return True
55
+ if "connection aborted" in msg:
56
+ return True
57
+ current = current.__cause__ or current.__context__
58
+ return False
59
+
60
+
61
+ def _session_lock(session: "Session") -> threading.Lock:
62
+ """Return the process-local lock that serializes serve_features calls on one session."""
63
+ with _SESSION_LOCKS_GUARD:
64
+ return _SESSION_LOCKS.setdefault(session, threading.Lock())
65
+
66
+
67
+ def _to_date(value: Any) -> datetime.date:
68
+ """Normalize a date-like value (str, date, datetime, Timestamp) to a datetime.date."""
69
+ return pd.Timestamp(value).date()
70
+
71
+
72
+ def _check_dynamic_view_feature_date(
73
+ session: "Session",
74
+ dynamic_view: str,
75
+ timestamp_col: str,
76
+ feature_date: str,
77
+ ) -> None:
78
+ """Raise if the session's dynamic view does not return the requested feature_date."""
79
+ rows = session.sql(f"SELECT {timestamp_col} FROM {dynamic_view} LIMIT 1").collect()
80
+ if not rows:
81
+ warnings.warn(
82
+ f"{dynamic_view} returned no rows; could not verify feature_date {feature_date}"
83
+ )
84
+ return
85
+ actual = rows[0][0]
86
+ if actual is None or _to_date(actual) != _to_date(feature_date):
87
+ raise ValueError(
88
+ f"{dynamic_view} returned {timestamp_col} {actual}, expected {feature_date}. "
89
+ f"The session's temporary view is not serving the requested parameters."
90
+ )
91
+
92
+
93
+ def _check_retrieved_feature_date(
94
+ features_df: pd.DataFrame,
95
+ feature_date_col: str,
96
+ feature_date: str,
97
+ ) -> None:
98
+ """Raise if a retrieved feature-date column holds any date other than feature_date."""
99
+ values = features_df[feature_date_col].dropna()
100
+ if values.empty:
101
+ warnings.warn(
102
+ f"{feature_date_col} is null for every row, so all features from that view are "
103
+ f"null. A spine timestamp earlier than feature_date {feature_date} matches no "
104
+ f"feature rows in the point-in-time join."
105
+ )
106
+ return
107
+ expected = _to_date(feature_date)
108
+ mismatched = sorted({d for d in values.map(_to_date) if d != expected})
109
+ if mismatched:
110
+ raise ValueError(
111
+ f"{feature_date_col} returned {mismatched}, expected {feature_date}. "
112
+ f"The session's temporary view is not serving the requested parameters."
113
+ )
114
+
115
+
116
+ def _warn_spine_before_feature_date(
117
+ features_df: pd.DataFrame,
118
+ spine_timestamp_col: str,
119
+ feature_date: str,
120
+ ) -> None:
121
+ """Warn when spine timestamps precede feature_date, which yields all-null features."""
122
+ matches = [c for c in features_df.columns if c.upper() == spine_timestamp_col.upper()]
123
+ if not matches or features_df.empty:
124
+ return
125
+ spine_ts = pd.to_datetime(features_df[matches[0]])
126
+ if getattr(spine_ts.dt, "tz", None) is not None:
127
+ spine_ts = spine_ts.dt.tz_localize(None)
128
+ n_early = int((spine_ts.dt.normalize() < pd.Timestamp(feature_date)).sum())
129
+ if n_early:
130
+ pct = n_early / len(features_df)
131
+ warnings.warn(
132
+ f"{n_early} spine rows ({pct:.2%}) have {spine_timestamp_col} earlier than "
133
+ f"feature_date {feature_date}. The point-in-time join returns null features "
134
+ f"for those rows."
135
+ )
136
+
137
+
138
+ def clear_feature_serving_locks(session: "Session") -> int:
139
+ """
140
+ Clear rows from the legacy FEATURE_SERVING_LOCKS table.
141
+
142
+ Deprecated: serve_features no longer uses a Snowflake lock table, so there is nothing
143
+ to get stuck. This function will be removed in a future release, and the
144
+ FEATURE_SERVING_LOCKS table can be dropped.
145
+
146
+ Parameters
147
+ ----------
148
+ session : snowflake.snowpark.Session
149
+ Active Snowflake Session object.
150
+
151
+ Returns
152
+ -------
153
+ int
154
+ Number of lock rows cleared, or 0 if none exist or the table is gone.
155
+ """
156
+ warnings.warn(
157
+ "gitlabds.clear_feature_serving_locks is deprecated and will be removed in a future "
158
+ "release. serve_features no longer uses the FEATURE_SERVING_LOCKS table.",
159
+ DeprecationWarning,
160
+ stacklevel=2,
161
+ )
162
+
163
+ lock_table = "FEATURE_SERVING_LOCKS"
164
+ lock_name = "FEATURE_SERVING"
165
+
166
+ try:
167
+ existing_locks = session.sql(f"""
168
+ SELECT session_id, acquired_at, expires_at,
169
+ CASE WHEN expires_at > CURRENT_TIMESTAMP() THEN 'ACTIVE' ELSE 'EXPIRED' END as status
170
+ FROM {lock_table}
171
+ WHERE lock_name = '{lock_name}'
172
+ ORDER BY acquired_at DESC
173
+ """).collect()
174
+
175
+ if existing_locks:
176
+ print("🔍 Current feature serving locks:")
177
+ for lock in existing_locks:
178
+ print(f" Session: {lock['SESSION_ID']}, "
179
+ f"Acquired: {lock['ACQUIRED_AT']}, "
180
+ f"Expires: {lock['EXPIRES_AT']}, "
181
+ f"Status: {lock['STATUS']}")
182
+ else:
183
+ print("✅ No existing locks found")
184
+ return 0
185
+
186
+ session.sql(f"""
187
+ DELETE FROM {lock_table}
188
+ WHERE lock_name = '{lock_name}'
189
+ """).collect()
190
+
191
+ locks_cleared = len(existing_locks)
192
+ print(f"🧹 Cleared {locks_cleared} feature serving locks")
193
+ return locks_cleared
194
+
195
+ except Exception as e:
196
+ print(f"❌ Error clearing locks: {e}")
197
+ return 0
198
+
199
+
200
+ def _udf_params(
201
+ fv_name: str,
202
+ feature_date: Optional[str],
203
+ lookback_window_value: Optional[Union[int, Dict[str, int]]],
204
+ lookback_window_unit: Optional[Union[str, Dict[str, str]]],
205
+ ) -> List[str]:
206
+ """Build the positional UDF arguments: SNAPSHOT_DATE, LOOKBACK_WINDOW_VALUE, LOOKBACK_WINDOW_UNIT."""
207
+ udf_params = []
208
+
209
+ if feature_date:
210
+ udf_params.append(f"'{feature_date}'::DATE")
211
+ print(f" Using snapshot date: {feature_date}")
212
+ else:
213
+ print(" Using UDF default snapshot date")
214
+
215
+ if isinstance(lookback_window_value, dict) and fv_name in lookback_window_value:
216
+ udf_params.append(str(lookback_window_value[fv_name]))
217
+ print(f" Using per-UDF lookback window value: {lookback_window_value[fv_name]}")
218
+ elif lookback_window_value is not None and not isinstance(lookback_window_value, dict):
219
+ udf_params.append(str(lookback_window_value))
220
+ print(f" Using global lookback window value: {lookback_window_value}")
221
+ else:
222
+ print(" Using UDF default lookback window value")
223
+
224
+ if isinstance(lookback_window_unit, dict) and fv_name in lookback_window_unit:
225
+ udf_params.append(f"'{lookback_window_unit[fv_name]}'")
226
+ print(f" Using per-UDF lookback window unit: {lookback_window_unit[fv_name]}")
227
+ elif lookback_window_unit is not None and not isinstance(lookback_window_unit, dict):
228
+ udf_params.append(f"'{lookback_window_unit}'")
229
+ print(f" Using global lookback window unit: {lookback_window_unit}")
230
+ else:
231
+ print(" Using UDF default lookback window unit")
232
+
233
+ return udf_params
234
+
235
+
236
+ def serve_features(
237
+ session: "Session",
238
+ feature_store: "FeatureStore",
239
+ feature_views_dict: Dict[str, str],
240
+ spine_df: Optional[Union[str, pd.DataFrame, "SnowparkDataFrame"]] = None,
241
+ feature_date: Optional[str] = None,
242
+ spine_timestamp_col: Optional[str] = None,
243
+ include_feature_view_timestamp_col: bool = False,
244
+ lookback_window_value: Optional[Union[int, Dict[str, int]]] = None,
245
+ lookback_window_unit: Optional[Union[str, Dict[str, str]]] = None,
246
+ lock_timeout_seconds: Optional[int] = None,
247
+ max_fetch_retries: int = 3,
248
+ fetch_retry_backoff_seconds: int = 30,
249
+ ) -> pd.DataFrame:
250
+ """
251
+ Serve features from Snowflake Feature Store with per-call UDF parameters.
252
+
253
+ Each feature view reads from a ``{FV}_DYNAMIC`` view. This function shadows that view
254
+ with a ``CREATE OR REPLACE TEMPORARY VIEW`` of the same name that calls ``{FV}_UDF``
255
+ with this call's parameters. Temporary views are scoped to the Snowpark session, so
256
+ callers on different sessions (including the same user in two notebooks) never see
257
+ each other's parameters, and the permanent ``_DYNAMIC`` view keeps its UDF defaults.
258
+ View and UDF names are qualified with the feature view's database and schema, so the
259
+ session's current schema does not matter.
260
+
261
+ The temporary views last until the session closes. A session that serves features and
262
+ then redeploys the permanent ``_DYNAMIC`` view keeps reading its temporary view until
263
+ the next ``serve_features`` call; use a fresh session to read the redeployed defaults.
264
+
265
+ Calls sharing one session object run one at a time: a process-local lock per session
266
+ is held for the whole call. Open one session per thread for real parallelism.
267
+
268
+ When ``feature_date`` is set, the served feature date is verified. With
269
+ ``spine_timestamp_col`` and ``include_feature_view_timestamp_col=True`` the returned
270
+ ``{FV}_{version}_{timestamp_col}`` column is checked; otherwise the session's temporary view is
271
+ queried before retrieval (``SELECT {timestamp_col} ... LIMIT 1``), which may run the
272
+ whole UDF.
273
+
274
+ Parameters
275
+ ----------
276
+ session : snowflake.snowpark.Session
277
+ Active Snowflake Session object. Must be the session ``feature_store`` was built
278
+ with (``feature_store._session``): retrieval reads through that session, so temp
279
+ views created on any other session are invisible to it.
280
+ feature_store : snowflake.ml.feature_store.FeatureStore
281
+ Initialized FeatureStore instance.
282
+ feature_views_dict : Dict[str, str]
283
+ Mapping of feature view names to versions (e.g., {"sales_activities": "1.0"}).
284
+ spine_df : Union[str, pd.DataFrame, snowflake.snowpark.DataFrame]
285
+ SQL query string, pandas DataFrame (converted to Snowpark), or Snowpark DataFrame.
286
+ Required by the Feature Store SDK; None raises ValueError.
287
+ feature_date : str, optional
288
+ Snapshot date for features (e.g., '2025-04-08'). If None, uses the UDF default and
289
+ the feature-date check is skipped.
290
+ spine_timestamp_col : str, optional
291
+ Name of the timestamp column in the spine for point-in-time lookup. If None,
292
+ point-in-time lookup is disabled. Spine timestamps earlier than ``feature_date``
293
+ match no feature rows and return null features; a warning is raised when that
294
+ happens.
295
+ include_feature_view_timestamp_col : bool, default=False
296
+ Include each feature view's timestamp column in the output. Only takes effect with
297
+ ``spine_timestamp_col``.
298
+ lookback_window_value : Union[int, Dict[str, int]], optional
299
+ Global integer (e.g., 6) or per-feature-view dict
300
+ (e.g., {"sales_activities_by_account": 3}).
301
+ lookback_window_unit : Union[str, Dict[str, str]], optional
302
+ Global string (e.g., 'months') or per-feature-view dict
303
+ (e.g., {"sales_activities_by_account": 'days'}).
304
+ lock_timeout_seconds : int, optional
305
+ Deprecated and ignored. The Snowflake lock table is no longer used.
306
+ max_fetch_retries : int, default=3
307
+ Maximum attempts for the result fetch (retrieve_feature_values +
308
+ to_arrow().to_pandas()) on transient network/streaming errors. 1 disables retries.
309
+ fetch_retry_backoff_seconds : int, default=30
310
+ Base sleep between fetch retries; grows linearly (attempt * backoff).
311
+
312
+ Returns
313
+ -------
314
+ pd.DataFrame
315
+ Combined features from all feature views, with lowercase column names.
316
+
317
+ Raises
318
+ ------
319
+ TypeError
320
+ When spine_df or feature_views_dict has the wrong type.
321
+ ValueError
322
+ When spine_df is None, feature_views_dict is empty, session is not the feature
323
+ store's session, or the served feature date does not match ``feature_date``.
324
+
325
+ Examples
326
+ --------
327
+ >>> df = serve_features(session, fs, {"sales_activities": "1.0"},
328
+ ... spine_df="SELECT account_id FROM accounts",
329
+ ... feature_date="2025-04-08")
330
+ """
331
+ if not isinstance(feature_views_dict, dict):
332
+ raise TypeError(f"feature_views_dict must be a dict, got {type(feature_views_dict)}")
333
+ if not feature_views_dict:
334
+ raise ValueError("feature_views_dict is empty")
335
+ if spine_df is None:
336
+ raise ValueError("spine_df is required - the Snowflake Feature Store SDK requires a spine DataFrame")
337
+ if not isinstance(spine_df, (str, pd.DataFrame, SnowparkDataFrame)):
338
+ raise TypeError(
339
+ f"spine_df must be a SQL query string, pandas DataFrame, or Snowpark DataFrame, "
340
+ f"got {type(spine_df)}"
341
+ )
342
+ if lock_timeout_seconds is not None:
343
+ warnings.warn(
344
+ "lock_timeout_seconds is deprecated and ignored: serve_features no longer uses a "
345
+ "Snowflake lock. It will be removed in a future release.",
346
+ DeprecationWarning,
347
+ stacklevel=2,
348
+ )
349
+ fs_session = getattr(feature_store, "_session", None)
350
+ if fs_session is None:
351
+ warnings.warn(
352
+ "Could not read feature_store._session to confirm it is the session passed to "
353
+ "serve_features. If they differ, retrieval cannot see this call's temporary "
354
+ "views and returns UDF-default features."
355
+ )
356
+ elif fs_session is not session:
357
+ raise ValueError(
358
+ "session must be the session feature_store was built with (feature_store._session). "
359
+ "Retrieval reads through the feature store's session, so temporary views created "
360
+ "on a different session are invisible to it and UDF-default features would be "
361
+ "returned. Pass session=feature_store._session."
362
+ )
363
+
364
+ call_id = f"call_{uuid.uuid4().hex[:8]}"
365
+ check_returned_column = (
366
+ bool(feature_date)
367
+ and spine_timestamp_col is not None
368
+ and include_feature_view_timestamp_col
369
+ )
370
+
371
+ lock = _session_lock(session)
372
+ if not lock.acquire(blocking=False):
373
+ print(f"⏳ [{call_id}] Waiting for another serve_features call on this session")
374
+ lock.acquire()
375
+
376
+ try:
377
+ feature_views_list = []
378
+ feature_date_cols = {}
379
+
380
+ for fv_name, version in feature_views_dict.items():
381
+ fv = feature_store.get_feature_view(fv_name, version)
382
+ feature_views_list.append(fv)
383
+
384
+ fv_schema = f"{fv.database}.{fv.schema}"
385
+ dynamic_view = f"{fv_schema}.{fv_name.upper()}_DYNAMIC"
386
+ udf_name = f"{fv_schema}.{fv_name.upper()}_UDF"
387
+ entity_id = fv.entities[0].join_keys[0]
388
+ timestamp_col = fv.timestamp_col
389
+
390
+ print(f"[{call_id}] {fv_name} v{version}:")
391
+ print(f" Dynamic View: {dynamic_view} (temporary)")
392
+ print(f" UDF: {udf_name}")
393
+ print(f" Entity: {entity_id}")
394
+ print(f" Timestamp: {timestamp_col}")
395
+
396
+ params_str = ", ".join(
397
+ _udf_params(fv_name, feature_date, lookback_window_value, lookback_window_unit)
398
+ )
399
+ update_view_sql = f"""
400
+ CREATE OR REPLACE TEMPORARY VIEW {dynamic_view} AS
401
+ SELECT * FROM TABLE({udf_name}({params_str}))
402
+ """
403
+ print(f" {update_view_sql.strip()}")
404
+ session.sql(update_view_sql).collect()
405
+ print(f" [{call_id}] Updated {dynamic_view}")
406
+
407
+ if not feature_date:
408
+ continue
409
+ if not timestamp_col:
410
+ warnings.warn(
411
+ f"{fv_name} has no timestamp_col; could not verify feature_date {feature_date}"
412
+ )
413
+ elif check_returned_column:
414
+ feature_date_cols[f"{fv_name}_{version}_{timestamp_col}".upper()] = (
415
+ dynamic_view,
416
+ timestamp_col,
417
+ )
418
+ else:
419
+ _check_dynamic_view_feature_date(session, dynamic_view, timestamp_col, feature_date)
420
+ print(f" [{call_id}] Verified {dynamic_view} serves {timestamp_col} {feature_date}")
421
+
422
+ if isinstance(spine_df, str):
423
+ snowpark_spine_df = session.sql(spine_df)
424
+ print(f"[{call_id}] Created spine from SQL query")
425
+ elif isinstance(spine_df, pd.DataFrame):
426
+ snowpark_spine_df = session.create_dataframe(spine_df)
427
+ print(f"[{call_id}] Created spine from pandas DataFrame")
428
+ else:
429
+ snowpark_spine_df = spine_df
430
+ print(f"[{call_id}] Using provided Snowpark DataFrame")
431
+
432
+ print(f"[{call_id}] Spine contains {snowpark_spine_df.count()} entities")
433
+
434
+ retrieve_kwargs = {
435
+ "spine_df": snowpark_spine_df,
436
+ "features": feature_views_list,
437
+ "include_feature_view_timestamp_col": include_feature_view_timestamp_col,
438
+ }
439
+ if spine_timestamp_col is not None:
440
+ retrieve_kwargs["spine_timestamp_col"] = spine_timestamp_col
441
+ print(f"[{call_id}] Using point-in-time lookup with timestamp column: {spine_timestamp_col}")
442
+ else:
443
+ print(f"[{call_id}] Point-in-time lookup disabled (no spine_timestamp_col)")
444
+
445
+ for fetch_attempt in range(1, max(1, max_fetch_retries) + 1):
446
+ try:
447
+ if fetch_attempt == 1:
448
+ print(f"[{call_id}] Starting retrieve_feature_values (this may take a while)...")
449
+ else:
450
+ print(f"[{call_id}] Retrying retrieve_feature_values "
451
+ f"(attempt {fetch_attempt}/{max_fetch_retries}) after transient error...")
452
+
453
+ snowpark_features = feature_store.retrieve_feature_values(**retrieve_kwargs)
454
+ combined_features = snowpark_features.to_arrow().to_pandas()
455
+ break
456
+ except Exception as fetch_err:
457
+ if _is_transient_fetch_error(fetch_err) and fetch_attempt < max_fetch_retries:
458
+ sleep_for = fetch_retry_backoff_seconds * fetch_attempt
459
+ print(f"⚠️ [{call_id}] Transient fetch error "
460
+ f"({type(fetch_err).__name__}: {fetch_err}). "
461
+ f"Sleeping {sleep_for}s before retry "
462
+ f"{fetch_attempt + 1}/{max_fetch_retries}...")
463
+ time.sleep(sleep_for)
464
+ continue
465
+ raise
466
+
467
+ returned_cols = {c.upper(): c for c in combined_features.columns}
468
+ for col, (dynamic_view, timestamp_col) in feature_date_cols.items():
469
+ if col in returned_cols:
470
+ _check_retrieved_feature_date(combined_features, returned_cols[col], feature_date)
471
+ else:
472
+ _check_dynamic_view_feature_date(session, dynamic_view, timestamp_col, feature_date)
473
+ print(f"[{call_id}] Verified {col} matches feature_date {feature_date}")
474
+
475
+ if spine_timestamp_col is not None and feature_date:
476
+ _warn_spine_before_feature_date(combined_features, spine_timestamp_col, feature_date)
477
+
478
+ combined_features.columns = map(str.lower, combined_features.columns)
479
+
480
+ print(f"✅ [{call_id}] Feature serving completed successfully")
481
+ return combined_features
482
+
483
+ finally:
484
+ lock.release()