Visitar URL original
fix: Bump decommissioned Snowflake Python UDF runtime from 3.9 to 3.10 (#6606) by zerafachris · Pull Request #6608 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: Redeploy Snowflake UDFs on every apply so runtime version change…
…s take effect

Addresses maintainer feedback from @ntkathole on #6608
(#6608 (comment)):
once materialization UDFs were deployed for a project, SnowflakeComputeEngine.update()
short-circuited on a `SHOW USER FUNCTIONS` check, and the SQL template used
`CREATE FUNCTION IF NOT EXISTS`. Together, this meant an already-deployed
project's UDFs were never redeployed, so changing `python_udf_runtime_version`
(e.g. after Snowflake decommissions a runtime, as in #6606) silently left the
stale runtime version in place.

Removes the early-return guard and switches every UDF creation statement in
snowflake_python_udfs_creation.sql from `CREATE FUNCTION IF NOT EXISTS` to
`CREATE OR REPLACE FUNCTION`, so `update()` always (re)deploys the UDFs using
whatever runtime version is currently configured. Adds unit tests covering
that update() no longer short-circuits and that a redeploy always uses the
current python_udf_runtime_version.

Signed-off-by: zerafachris <christopher.zerafa@blocklabs.io>
  • Loading branch information
zerafachris committed Jul 18, 2026
commit 21cb098b8db3ac5b7b687069dd1cd056e5363e40
Original file line number Diff line number Diff line change
Expand Up @@ -144,25 +144,18 @@ def update(
entities_to_delete: Sequence[Entity],
entities_to_keep: Sequence[Entity],
):
stage_context = f'"{self.repo_config.batch_engine.database}"."{self.repo_config.batch_engine.schema_}"'
stage_path = f'{stage_context}."feast_{project}"'
stage_path = f'"{self.repo_config.batch_engine.database}"."{self.repo_config.batch_engine.schema_}"."feast_{project}"'
with GetSnowflakeConnection(self.repo_config.batch_engine) as conn:
query = f"SHOW USER FUNCTIONS LIKE 'FEAST_{project.upper()}%' IN SCHEMA {stage_context}"
cursor = execute_snowflake_statement(conn, query)
function_list = pd.DataFrame(
cursor.fetchall(),
columns=[column.name for column in cursor.description],
)

# if the SHOW FUNCTIONS query returns results,
# assumes that the materialization functions have been deployed
if len(function_list.index) > 0:
click.echo(
f"Materialization functions for {Style.BRIGHT + Fore.GREEN}{project}{Style.RESET_ALL} already detected."
)
click.echo()
return None

# Always (re)deploy the materialization functions, using
# `CREATE OR REPLACE FUNCTION` below, instead of skipping deployment
# when functions already exist. Previously, an early return here
# (triggered by a `SHOW USER FUNCTIONS` check) combined with
# `CREATE FUNCTION IF NOT EXISTS` in the SQL template meant that
# once UDFs were deployed for a project, they were never
# redeployed -- so a config change to `python_udf_runtime_version`
# (e.g. after Snowflake decommissions a runtime) would silently
# keep using the stale, already-deployed runtime version. See
# https://github.com/feast-dev/feast/pull/6608#discussion_r3602461959.
click.echo(
f"Deploying materialization functions for {Style.BRIGHT + Fore.GREEN}{project}{Style.RESET_ALL}"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,124 +1,124 @@
CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_binary_to_bytes_proto(df BINARY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_binary_to_bytes_proto(df BINARY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_binary_to_bytes_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_varchar_to_string_proto(df VARCHAR)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_varchar_to_string_proto(df VARCHAR)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_varchar_to_string_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_bytes_to_list_bytes_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_bytes_to_list_bytes_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_bytes_to_list_bytes_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_varchar_to_list_string_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_varchar_to_list_string_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_varchar_to_list_string_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_number_to_list_int32_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_number_to_list_int32_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_number_to_list_int32_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_number_to_list_int64_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_number_to_list_int64_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_number_to_list_int64_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_float_to_list_double_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_float_to_list_double_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_float_to_list_double_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_boolean_to_list_bool_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_boolean_to_list_bool_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_boolean_to_list_bool_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_array_timestamp_to_list_unix_timestamp_proto(df ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_array_timestamp_to_list_unix_timestamp_proto(df ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_array_timestamp_to_list_unix_timestamp_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_number_to_int32_proto(df NUMBER)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_number_to_int32_proto(df NUMBER)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_number_to_int32_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_number_to_int64_proto(df NUMBER)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_number_to_int64_proto(df NUMBER)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_number_to_int64_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_float_to_double_proto(df DOUBLE)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_float_to_double_proto(df DOUBLE)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_float_to_double_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_boolean_to_bool_proto(df BOOLEAN)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_boolean_to_bool_proto(df BOOLEAN)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_boolean_to_bool_boolean_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_snowflake_timestamp_to_unix_timestamp_proto(df NUMBER)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_snowflake_timestamp_to_unix_timestamp_proto(df NUMBER)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_snowflake_timestamp_to_unix_timestamp_proto'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_serialize_entity_keys(names ARRAY, data ARRAY, types ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_serialize_entity_keys(names ARRAY, data ARRAY, types ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
PACKAGES = ('protobuf', 'pandas')
HANDLER = 'feast.infra.utils.snowflake.snowpark.snowflake_udfs.feast_serialize_entity_keys'
IMPORTS = ('@STAGE_HOLDER/feast.zip');

CREATE FUNCTION IF NOT EXISTS feast_PROJECT_NAME_entity_key_proto_to_string(names ARRAY, data ARRAY, types ARRAY)
CREATE OR REPLACE FUNCTION feast_PROJECT_NAME_entity_key_proto_to_string(names ARRAY, data ARRAY, types ARRAY)
RETURNS BINARY
LANGUAGE PYTHON
RUNTIME_VERSION = 'RUNTIME_VERSION_HOLDER'
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Regression tests for SnowflakeComputeEngine.update().

See https://github.com/feast-dev/feast/pull/6608#discussion_r3602461959: a
maintainer pointed out that once materialization UDFs were deployed for a
project, a `SHOW USER FUNCTIONS` check made `update()` return early on every
subsequent call, and the SQL template used `CREATE FUNCTION IF NOT EXISTS`.
Together, this meant a user changing `python_udf_runtime_version` (e.g. after
Snowflake decommissions a runtime) would never actually get the new runtime
version deployed -- the stale, already-deployed UDFs were left untouched.

The fix removes the early return and switches the SQL template to
`CREATE OR REPLACE FUNCTION`, so `update()` always (re)deploys the UDFs using
the currently configured runtime version.
"""

import shutil
import tempfile
from unittest.mock import MagicMock, patch

from feast.infra.compute_engines.snowflake.snowflake_engine import (
SnowflakeComputeEngine,
SnowflakeComputeEngineConfig,
)

MODULE = "feast.infra.compute_engines.snowflake.snowflake_engine"


def _make_engine(python_udf_runtime_version: str = "3.10") -> SnowflakeComputeEngine:
repo_config = MagicMock()
repo_config.offline_store.type = "snowflake.offline"
repo_config.batch_engine = SnowflakeComputeEngineConfig(
database="FEAST",
schema="PUBLIC",
python_udf_runtime_version=python_udf_runtime_version,
)

return SnowflakeComputeEngine(
repo_config=repo_config,
offline_store=MagicMock(),
online_store=MagicMock(),
)


def _run_update(engine: SnowflakeComputeEngine, mock_execute: MagicMock) -> None:
mock_conn_cm = MagicMock()
mock_conn_cm.__enter__ = MagicMock(return_value=MagicMock())
mock_conn_cm.__exit__ = MagicMock(return_value=False)

def _fake_package_snowpark_zip(project_name):
copy_path = tempfile.mkdtemp()
return copy_path, f"{copy_path}/feast.zip"

with (
patch(f"{MODULE}.GetSnowflakeConnection", return_value=mock_conn_cm),
patch(f"{MODULE}.execute_snowflake_statement", mock_execute),
patch(f"{MODULE}.package_snowpark_zip", side_effect=_fake_package_snowpark_zip),
patch(f"{MODULE}.shutil.rmtree", side_effect=shutil.rmtree),
):
engine.update(
project="myproject",
views_to_delete=[],
views_to_keep=[],
entities_to_delete=[],
entities_to_keep=[],
)


def test_update_never_short_circuits_on_existing_functions():
"""update() must not query `SHOW USER FUNCTIONS` and skip deployment --
that early-return path is what caused stale runtime versions to stick
around after a config change.
"""
engine = _make_engine()
mock_execute = MagicMock()

_run_update(engine, mock_execute)

executed_queries = [call.args[1] for call in mock_execute.call_args_list]
assert not any("SHOW USER FUNCTIONS" in q for q in executed_queries), (
"update() should no longer perform a SHOW USER FUNCTIONS check that "
"causes it to skip (re)deploying the UDFs"
)
assert any("CREATE OR REPLACE FUNCTION" in q for q in executed_queries), (
"update() should deploy UDFs using CREATE OR REPLACE FUNCTION so "
"redeploys pick up a changed python_udf_runtime_version"
)


def test_update_redeploys_with_current_runtime_version_on_every_call():
"""Calling update() twice (simulating two `feast apply` runs, e.g. before
and after a user bumps `python_udf_runtime_version`) must deploy the UDFs
both times, each time using whatever runtime version is currently
configured.
"""
engine = _make_engine(python_udf_runtime_version="3.11")
mock_execute = MagicMock()

_run_update(engine, mock_execute)
first_call_count = mock_execute.call_count
assert first_call_count > 0

_run_update(engine, mock_execute)
second_call_count = mock_execute.call_count
assert second_call_count == 2 * first_call_count, (
"the second update() call should re-run the full deployment (no "
"early return), just like the first"
)

executed_queries = [call.args[1] for call in mock_execute.call_args_list]
create_function_queries = [
q for q in executed_queries if "CREATE OR REPLACE FUNCTION" in q
]
assert create_function_queries, "expected CREATE OR REPLACE FUNCTION queries"
assert all("RUNTIME_VERSION = '3.11'" in q for q in create_function_queries), (
"every deployed UDF should use the currently configured "
"python_udf_runtime_version ('3.11'), including on redeploy"
)
Loading