4141 to_datetime ,
4242 yesterday_ds ,
4343 to_timestamp ,
44+ time_like_to_str ,
45+ is_relative ,
4446)
4547from sqlmesh .utils .errors import NoChangesPlanError , PlanError
4648
@@ -55,6 +57,7 @@ class PlanBuilder:
5557 start: The start time to backfill data.
5658 end: The end time to backfill data.
5759 execution_time: The date/time time reference to use for execution time. Defaults to now.
60+ If :start or :end are relative time expressions, they are interpreted as relative to the :execution_time
5861 apply: The callback to apply the plan.
5962 restate_models: A list of models for which the data should be restated for the time range
6063 specified in this plan. Note: models defined outside SQLMesh (external) won't be a part
@@ -137,7 +140,14 @@ def __init__(
137140 self ._include_unmodified = include_unmodified
138141 self ._restate_models = set (restate_models ) if restate_models is not None else None
139142 self ._effective_from = effective_from
143+
144+ # note: this deliberately doesnt default to now() here.
145+ # There may be an significant delay between the PlanBuilder producing a Plan and the Plan actually being run
146+ # so if execution_time=None is passed to the PlanBuilder, then the resulting Plan should also have execution_time=None
147+ # in order to prevent the Plan that was intended to run "as at now" from having "now" fixed to some time in the past
148+ # ref: https://github.com/TobikoData/sqlmesh/pull/4702#discussion_r2140696156
140149 self ._execution_time = execution_time
150+
141151 self ._backfill_models = backfill_models
142152 self ._end = end or default_end
143153 self ._apply = apply
@@ -172,6 +182,26 @@ def is_start_and_end_allowed(self) -> bool:
172182 """Indicates whether this plan allows to set the start and end dates."""
173183 return self ._is_dev or bool (self ._restate_models )
174184
185+ @property
186+ def start (self ) -> t .Optional [TimeLike ]:
187+ if self ._start and is_relative (self ._start ):
188+ # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
189+ return to_datetime (self ._start , relative_base = to_datetime (self .execution_time ))
190+ return self ._start
191+
192+ @property
193+ def end (self ) -> t .Optional [TimeLike ]:
194+ if self ._end and is_relative (self ._end ):
195+ # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
196+ return to_datetime (self ._end , relative_base = to_datetime (self .execution_time ))
197+ return self ._end
198+
199+ @cached_property
200+ def execution_time (self ) -> TimeLike :
201+ # this is cached to return a stable value from now() in the places where the execution time matters for resolving relative date strings
202+ # during the plan building process
203+ return self ._execution_time or now ()
204+
175205 def set_start (self , new_start : TimeLike ) -> PlanBuilder :
176206 self ._start = new_start
177207 self .override_start = True
@@ -256,7 +286,8 @@ def build(self) -> Plan:
256286 )
257287
258288 restatements = self ._build_restatements (
259- dag , earliest_interval_start (self ._context_diff .snapshots .values ())
289+ dag ,
290+ earliest_interval_start (self ._context_diff .snapshots .values (), self .execution_time ),
260291 )
261292 models_to_backfill = self ._build_models_to_backfill (dag , restatements )
262293
@@ -266,11 +297,17 @@ def build(self) -> Plan:
266297 # model should be ignored.
267298 interval_end_per_model = None
268299
300+ # this deliberately uses the passed in self._execution_time and not self.execution_time cached property
301+ # the reason is because that there can be a delay between the Plan being built and the Plan being actually run,
302+ # so this ensures that an _execution_time of None can be propagated to the Plan and thus be re-resolved to
303+ # the current timestamp of when the Plan is eventually run
304+ plan_execution_time = self ._execution_time
305+
269306 plan = Plan (
270307 context_diff = self ._context_diff ,
271308 plan_id = self ._plan_id ,
272- provided_start = self ._start ,
273- provided_end = self ._end ,
309+ provided_start = self .start ,
310+ provided_end = self .end ,
274311 is_dev = self ._is_dev ,
275312 skip_backfill = self ._skip_backfill ,
276313 empty_backfill = self ._empty_backfill ,
@@ -289,7 +326,7 @@ def build(self) -> Plan:
289326 selected_models_to_backfill = self ._backfill_models ,
290327 models_to_backfill = models_to_backfill ,
291328 effective_from = self ._effective_from ,
292- execution_time = self . _execution_time ,
329+ execution_time = plan_execution_time ,
293330 end_bounded = self ._end_bounded ,
294331 ensure_finalized_snapshots = self ._ensure_finalized_snapshots ,
295332 user_provided_flags = self ._user_provided_flags ,
@@ -739,6 +776,18 @@ def _ensure_valid_date_range(self) -> None:
739776 "The start and end dates can't be set for a production plan without restatements."
740777 )
741778
779+ if (start := self .start ) and (end := self .end ):
780+ if to_datetime (start ) > to_datetime (end ):
781+ raise PlanError (
782+ f"Plan end date: '{ time_like_to_str (end )} ' must be after the plan start date: '{ time_like_to_str (start )} '"
783+ )
784+
785+ if end := self .end :
786+ if to_datetime (end ) > to_datetime (self .execution_time ):
787+ raise PlanError (
788+ f"Plan end date: '{ time_like_to_str (end )} ' cannot be in the future (execution time: '{ time_like_to_str (self .execution_time )} ')"
789+ )
790+
742791 def _ensure_no_forward_only_revert (self ) -> None :
743792 """Ensures that a previously superseded breaking / non-breaking snapshot is not being
744793 used again to replace an existing forward-only snapshot with the same version.
0 commit comments