@@ -1671,6 +1671,176 @@ def test_plan_start_ahead_of_end(copy_to_temp_path):
16711671 context .close ()
16721672
16731673
1674+ @pytest .mark .slow
1675+ def test_plan_execution_time_ahead_of_prod_frontier (copy_to_temp_path ):
1676+ """An explicitly provided `execution_time` should be able to extend the plan's default end
1677+ past the recorded prod frontier, since it represents the plan's effective "now". Without
1678+ this, an explicit execution_time beyond the last applied interval is silently ignored and
1679+ the plan reports no changes/no backfill even though new intervals are due.
1680+
1681+ See: https://github.com/SQLMesh/sqlmesh/issues/5640
1682+ """
1683+ path = copy_to_temp_path ("examples/sushi" )
1684+ with time_machine .travel ("2024-01-02 00:00:00 UTC" ):
1685+ context = Context (paths = path , gateway = "duckdb_persistent" )
1686+ context .plan ("prod" , no_prompts = True , auto_apply = True )
1687+ assert all (
1688+ i == to_timestamp ("2024-01-02" )
1689+ for i in context .state_sync .max_interval_end_per_model ("prod" ).values ()
1690+ )
1691+ context .close ()
1692+
1693+ # No model changes, but a subsequent plan explicitly passes an execution_time that is ahead
1694+ # of the recorded prod frontier (2024-01-02). This mimics running
1695+ # `sqlmesh plan --execution-time '2024-01-05'` a few days later.
1696+ with time_machine .travel ("2024-01-06 00:00:00 UTC" ):
1697+ context = Context (paths = path , gateway = "duckdb_persistent" )
1698+ plan = context .plan_builder ("prod" , execution_time = "2024-01-05" ).build ()
1699+ assert plan .requires_backfill
1700+ assert to_timestamp (plan .end ) == to_timestamp ("2024-01-05" )
1701+ context .apply (plan )
1702+ # The seed model isn't backfilled on a cron schedule like the other models, so its
1703+ # recorded interval end doesn't advance to the new execution time (same exclusion as
1704+ # test_plan_seed_model_excluded_from_default_end).
1705+ max_ends = context .state_sync .max_interval_end_per_model ("prod" )
1706+ assert all (
1707+ i == to_timestamp ("2024-01-05" )
1708+ for fqn , i in max_ends .items ()
1709+ if "waiter_names" not in fqn
1710+ )
1711+ context .close ()
1712+
1713+ # Sanity check that the downward clamp is unaffected: an explicit execution_time that is
1714+ # *behind* the recorded prod frontier should still clamp the default end down to it (this is
1715+ # already covered for the dev case by test_plan_execution_time_start_end).
1716+ with time_machine .travel ("2024-01-07 00:00:00 UTC" ):
1717+ context = Context (paths = path , gateway = "duckdb_persistent" )
1718+ plan = context .plan_builder ("prod" , execution_time = "2024-01-04" ).build ()
1719+ assert not plan .requires_backfill
1720+ assert to_timestamp (plan .end ) == to_timestamp ("2024-01-04" )
1721+ context .close ()
1722+
1723+
1724+ def _write_daily_and_weekly_model_project (tmp_path : Path ) -> None :
1725+ """Minimal 2-model project used to exercise the interaction between execution_time and
1726+ multiple, differently-cadenced models' recorded prod frontiers. A daily and a weekly model
1727+ are used so that, after an initial backfill, they end up with different recorded interval
1728+ ends (the weekly model's frontier lags the daily model's), which is what the original bug
1729+ report's project topology looked like.
1730+ """
1731+ (tmp_path / "models" ).mkdir ()
1732+ (tmp_path / "config.yaml" ).write_text (
1733+ """
1734+ model_defaults:
1735+ dialect: duckdb
1736+ """
1737+ )
1738+ (tmp_path / "models" / "daily_model.sql" ).write_text (
1739+ """
1740+ MODEL (
1741+ name daily_model,
1742+ kind INCREMENTAL_BY_TIME_RANGE (
1743+ time_column start_dt
1744+ ),
1745+ start '2024-01-01',
1746+ cron '@daily'
1747+ );
1748+
1749+ select @start_ds as start_ds, @end_ds as end_ds, @start_dt as start_dt, @end_dt as end_dt;
1750+ """
1751+ )
1752+ (tmp_path / "models" / "weekly_model.sql" ).write_text (
1753+ """
1754+ MODEL (
1755+ name weekly_model,
1756+ kind INCREMENTAL_BY_TIME_RANGE (
1757+ time_column start_dt
1758+ ),
1759+ start '2024-01-01',
1760+ cron '@weekly'
1761+ );
1762+
1763+ select @start_ds as start_ds, @end_ds as end_ds, @start_dt as start_dt, @end_dt as end_dt;
1764+ """
1765+ )
1766+
1767+
1768+ def _missing_intervals_by_name (plan : Plan ) -> t .Dict [str , t .Tuple [t .Tuple [int , int ], ...]]:
1769+ return {si .snapshot_id .name : tuple (si .merged_intervals ) for si in plan .missing_intervals }
1770+
1771+
1772+ def test_plan_execution_time_ahead_of_prod_frontier_matches_run_for_all_models (tmp_path : Path ):
1773+ """Locks in that raising `max_interval_end_per_model` for an explicitly provided
1774+ `execution_time` sweeps in *every* model with a recorded prod frontier, not just
1775+ modified/selected ones. This is intentional, not an oversight: it's what makes a plain,
1776+ unscoped `sqlmesh plan --execution-time X` in prod report the exact same missing intervals
1777+ that `sqlmesh plan --run --execution-time X` would report at the same simulated time - the
1778+ parity the original bug report asks for (https://github.com/SQLMesh/sqlmesh/issues/5640,
1779+ which cites `--run` as already having the correct behavior). A future change that "scopes"
1780+ the raise down to fewer models would silently break this `plan`/`plan --run` parity and
1781+ should fail this test.
1782+ """
1783+ _write_daily_and_weekly_model_project (tmp_path )
1784+ context = Context (paths = tmp_path )
1785+
1786+ # Catch both models up, but to different frontiers: the weekly model's cadence means its
1787+ # last fully-elapsed interval (2024-01-14) is a day behind the daily model's (2024-01-15).
1788+ context .plan (auto_apply = True , no_prompts = True , execution_time = "2024-01-15 00:00:01" )
1789+ max_ends = context .state_sync .max_interval_end_per_model ("prod" )
1790+ assert max_ends ['"daily_model"' ] == to_timestamp ("2024-01-15" )
1791+ assert max_ends ['"weekly_model"' ] == to_timestamp ("2024-01-14" )
1792+
1793+ # A plain, unscoped prod plan (no model changes, no --select-model/--backfill-model, no
1794+ # restatement) with execution_time set well ahead of both frontiers and no explicit end.
1795+ execution_time = "2024-01-25 00:00:01"
1796+ plan = context .plan_builder ("prod" , execution_time = execution_time ).build ()
1797+ assert plan .requires_backfill
1798+ assert to_timestamp (plan .end ) == to_timestamp (execution_time )
1799+
1800+ missing = _missing_intervals_by_name (plan )
1801+ # Both models show missing intervals, even though only the daily model's cadence would
1802+ # naturally put it "due" first - the weekly model is swept in too.
1803+ assert set (missing ) == {'"daily_model"' , '"weekly_model"' }
1804+
1805+ # An equivalent `plan --run` at the same execution_time computes missing intervals with no
1806+ # caps at all. If it matches exactly, that confirms the plain-plan raise reproduces the
1807+ # `--run` result rather than under- or over-shooting it.
1808+ run_plan = context .plan_builder ("prod" , execution_time = execution_time , run = True ).build ()
1809+ assert run_plan .requires_backfill
1810+ assert _missing_intervals_by_name (run_plan ) == missing
1811+
1812+
1813+ def test_plan_execution_time_ahead_of_prod_frontier_with_explicit_end (tmp_path : Path ):
1814+ """When the user provides an explicit `end` alongside `execution_time`, the new
1815+ `end is None` guard means the per-model interval end caps are never raised towards
1816+ `execution_time` in the first place - the plan's end is exactly the explicit end, and
1817+ the pre-existing `PlanBuilder.override_end` behavior (which drops the per-model caps
1818+ entirely once `end` is explicit, see sqlmesh/core/plan/builder.py) takes over instead, same
1819+ as it did before this fix. This locks in that combining explicit `end` with a far-future
1820+ `execution_time` cannot make the backfill silently jump past the requested end.
1821+ """
1822+ _write_daily_and_weekly_model_project (tmp_path )
1823+ context = Context (paths = tmp_path )
1824+ context .plan (auto_apply = True , no_prompts = True , execution_time = "2024-01-15 00:00:01" )
1825+
1826+ # Explicit start/end is only allowed for dev plans (or prod plans with restatements), so use
1827+ # a dev plan here; the guard being exercised doesn't depend on which of those it is.
1828+ plan = context .plan_builder (
1829+ "dev" ,
1830+ execution_time = "2024-01-30 00:00:01" ,
1831+ end = "2024-01-21" ,
1832+ include_unmodified = True ,
1833+ ).build ()
1834+ assert plan .requires_backfill
1835+ # The plan's end matches the explicit end exactly - it is not raised towards execution_time.
1836+ assert to_timestamp (plan .end ) == to_timestamp ("2024-01-21" )
1837+
1838+ # Missing intervals for both models stop at the explicit end and never reach anywhere near
1839+ # the much-later execution_time.
1840+ for si in plan .missing_intervals :
1841+ assert si .merged_intervals [- 1 ][1 ] <= to_timestamp ("2024-01-22" )
1842+
1843+
16741844@pytest .mark .slow
16751845def test_plan_seed_model_excluded_from_default_end (copy_to_temp_path : t .Callable ):
16761846 path = copy_to_temp_path ("examples/sushi" )
@@ -3254,7 +3424,12 @@ def test_plan_min_intervals(tmp_path: Path):
32543424 plan = context .plan (execution_time = current_time )
32553425
32563426 assert to_datetime (plan .start ) == to_datetime ("2020-01-01 00:00:00" )
3257- assert to_datetime (plan .end ) == to_datetime ("2020-02-01 00:00:00" )
3427+ # the explicitly provided execution_time is 1 second past the day-aligned frontier of the
3428+ # other, already-caught-up models, so the plan end now matches it exactly instead of being
3429+ # capped at that frontier (see https://github.com/SQLMesh/sqlmesh/issues/5640). This
3430+ # doesn't change which intervals are missing below since they're still bucketed by each
3431+ # model's own cron.
3432+ assert to_datetime (plan .end ) == to_datetime ("2020-02-01 00:00:01" )
32583433 assert to_datetime (plan .execution_time ) == to_datetime ("2020-02-01 00:00:01" )
32593434
32603435 def _get_missing_intervals (plan : Plan , name : str ) -> t .List [t .Tuple [datetime , datetime ]]:
0 commit comments