1010from unittest import mock
1111from unittest .mock import patch
1212import logging
13+ from IPython .utils .capture import capture_output
14+
1315
1416import time_machine
1517from pytest_mock .plugin import MockerFixture
@@ -3846,36 +3848,45 @@ def test_external_model_freshness(ctx: TestContext, mocker: MockerFixture, tmp_p
38463848 if not adapter .SUPPORTS_EXTERNAL_MODEL_FRESHNESS :
38473849 pytest .skip ("This test only runs for engines that support external model freshness" )
38483850
3849- def _run_plan (
3850- sqlmesh_context : Context , restate_models : t .Optional [t .List [str ]] = None
3851- ) -> PlanResults :
3852- plan : Plan = sqlmesh_context .plan (
3853- auto_apply = True , no_prompts = True , restate_models = restate_models
3851+ def _assert_snapshot_last_altered_ts (context : Context , snapshot_id : str , timestamp : datetime ):
3852+ from sqlmesh .utils .date import to_datetime
3853+
3854+ snapshot = context .state_sync .get_snapshots ([snapshot_id ])[snapshot_id ]
3855+ assert to_datetime (snapshot .last_altered_ts ).replace (microsecond = 0 ) == timestamp .replace (
3856+ microsecond = 0
38543857 )
3855- return PlanResults .create (plan , ctx , schema )
38563858
38573859 import sqlmesh
38583860
38593861 spy = mocker .spy (sqlmesh .core .snapshot .evaluator .SnapshotEvaluator , "evaluate" )
38603862
38613863 def _assert_model_evaluation (lambda_func , was_evaluated , day_delta = 0 ):
3862- call_count_before = spy .call_count
3863- logger = logging .getLogger ("sqlmesh.core.scheduler" )
3864-
3865- with time_machine .travel (now (minute_floor = False ) + timedelta (days = day_delta )):
3866- with mock .patch .object (logger , "info" ) as mock_logger :
3867- lambda_func ()
3868-
3869- evaluation_skipped_log = any (
3870- "Skipping evaluation for snapshot" in call [0 ][0 ] for call in mock_logger .call_args_list
3871- )
3872-
3864+ spy .reset_mock ()
3865+ timestamp = now (minute_floor = False ) + timedelta (days = day_delta )
3866+ with time_machine .travel (timestamp , tick = False ):
3867+ with capture_output () as output :
3868+ plan_or_run_result = lambda_func ()
3869+
3870+ evaluate_function_called = spy .call_count == 1
3871+ signal_was_checked = "Checking signals for" in output .stdout
3872+ restatement_plan = isinstance (plan_or_run_result , Plan ) and plan_or_run_result .restatements
3873+ if restatement_plan :
3874+ # Restatement plans exclude this signal so we expect the actual evaluation
3875+ # to happen but not through the signal
3876+ assert evaluate_function_called
3877+ assert not signal_was_checked
3878+ return
3879+
3880+ # All other cases (e.g normal plans or runs) will check the freshness signal
3881+ assert signal_was_checked
38733882 if was_evaluated :
3874- assert not evaluation_skipped_log
3875- assert spy . call_count == call_count_before + 1
3883+ assert "All ready" in output . stdout
3884+ assert evaluate_function_called
38763885 else :
3877- assert evaluation_skipped_log
3878- assert spy .call_count == call_count_before
3886+ assert "None ready" in output .stdout
3887+ assert not evaluate_function_called
3888+
3889+ return timestamp , plan_or_run_result
38793890
38803891 # Create & initialize schema
38813892 schema = ctx .add_test_suffix (TEST_SCHEMA )
@@ -3912,7 +3923,10 @@ def _assert_model_evaluation(lambda_func, was_evaluated, day_delta=0):
39123923 MODEL (
39133924 name { model_name } ,
39143925 start '2024-01-01',
3915- kind FULL
3926+ kind FULL,
3927+ signals (
3928+ freshness(),
3929+ )
39163930 );
39173931
39183932 SELECT col1 * col2 AS col FROM { external_table1 } , { external_table2 } ;
@@ -3924,23 +3938,47 @@ def _set_config(gateway: str, config: Config) -> None:
39243938
39253939 context = ctx .create_context (path = tmp_path , config_mutator = _set_config )
39263940
3927- # Case 1: Model is evaluated on first insertion
3928- _assert_model_evaluation (lambda : _run_plan (context ), was_evaluated = True )
3941+ # Case 1: Model is evaluated for the first plan
3942+ prod_plan_ts , prod_plan = _assert_model_evaluation (
3943+ lambda : context .plan (auto_apply = True , no_prompts = True ), was_evaluated = True
3944+ )
3945+
3946+ prod_snapshot_id = next (iter (prod_plan .context_diff .new_snapshots ))
3947+ _assert_snapshot_last_altered_ts (context , prod_snapshot_id , prod_plan_ts )
39293948
39303949 # Case 2: Model is NOT evaluated on run if external models are not fresh
3931- _assert_model_evaluation (lambda : context .run (), was_evaluated = False , day_delta = 2 )
3950+ _assert_model_evaluation (lambda : context .run (), was_evaluated = False , day_delta = 1 )
39323951
3933- # Case 3: Model is evaluated on run if any external model is fresh
3934- adapter .execute (f"INSERT INTO { external_table2 } (col2) VALUES (3)" , quote_identifiers = False )
3952+ # Case 3: Differentiate last_altered_ts between snapshots with shared version
3953+ # For instance, creating a FORWARD_ONLY change in dev (reusing the version but creating a dev preview) should not cause
3954+ # the prod snapshot's last_altered_ts to be updated when fetched from the state sync
3955+ model_path .write_text (model_path .read_text ().replace ("col1 * col2" , "col1 + col2" ))
3956+ context .load ()
3957+ dev_plan_ts = now (minute_floor = False ) + timedelta (days = 2 )
3958+ with time_machine .travel (dev_plan_ts , tick = False ):
3959+ dev_plan = context .plan (
3960+ environment = "dev" , forward_only = True , auto_apply = True , no_prompts = True
3961+ )
3962+
3963+ context .state_sync .clear_cache ()
3964+ dev_snapshot_id = next (iter (dev_plan .context_diff .new_snapshots ))
3965+ _assert_snapshot_last_altered_ts (context , dev_snapshot_id , dev_plan_ts )
3966+ _assert_snapshot_last_altered_ts (context , prod_snapshot_id , prod_plan_ts )
39353967
3968+ # Case 4: Model is evaluated on run if any external model is fresh
3969+ adapter .execute (f"INSERT INTO { external_table2 } (col2) VALUES (3)" , quote_identifiers = False )
39363970 _assert_model_evaluation (lambda : context .run (), was_evaluated = True , day_delta = 2 )
39373971
3938- # Case 4: Model is evaluated on a restatement plan even if the external model is not fresh
3972+ # Case 5: Model is evaluated if changed (case 3) even if the external model is not fresh
3973+ model_path .write_text (model_path .read_text ().replace ("col1 + col2" , "col1 * col2 * 5" ))
3974+ context .load ()
39393975 _assert_model_evaluation (
3940- lambda : _run_plan ( context , restate_models = [ model_name ] ), was_evaluated = True , day_delta = 3
3976+ lambda : context . plan ( auto_apply = True , no_prompts = True ), was_evaluated = True , day_delta = 3
39413977 )
39423978
3943- # Case 5: Model is evaluated if changed even if the external model is not fresh
3944- model_path .write_text (model_path .read_text ().replace ("col1 * col2" , "col1 + col2" ))
3945- context .load ()
3946- _assert_model_evaluation (lambda : _run_plan (context ), was_evaluated = True , day_delta = 2 )
3979+ # Case 6: Model is evaluated on a restatement plan even if the external model is not fresh
3980+ _assert_model_evaluation (
3981+ lambda : context .plan (restate_models = [model_name ], auto_apply = True , no_prompts = True ),
3982+ was_evaluated = True ,
3983+ day_delta = 4 ,
3984+ )
0 commit comments