Skip to content

Commit 7043ed7

Browse files
committed
add more granular bot controls for preview plans
Signed-off-by: Jesse Hodges <hodges.jesse@gmail.com>
1 parent 7c31c5c commit 7043ed7

8 files changed

Lines changed: 233 additions & 2 deletions

File tree

docs/integrations/github.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,10 @@ Below is an example of how to define the default config for the bot in either YA
293293
| `enable_deploy_command` | Indicates if the `/deploy` command should be enabled in order to allowed synchronized deploys to production. Default: `False` | bool | N |
294294
| `command_namespace` | The namespace to use for SQLMesh commands. For example if you provide `#SQLMesh` as a value then commands will be expected in the format of `#SQLMesh/<command>`. Default: `None` meaning no namespace is used. | string | N |
295295
| `auto_categorize_changes` | Auto categorization behavior to use for the bot. If not provided then the project-wide categorization behavior is used. See [Auto-categorize model changes](https://sqlmesh.readthedocs.io/en/stable/guides/configuration/#auto-categorize-model-changes) for details. | dict | N |
296-
| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start or to 1 day ago if [data previews](../concepts/plans.md#data-preview) need to be computed. | str | N |
296+
| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start. | str | N |
297297
| `pr_min_intervals` | Intended for use when `default_pr_start` is set to a relative time, eg `1 week ago`. This ensures that at least this many intervals across every model are included for backfill in the PR environment. Without this, models with an interval unit wider than `default_pr_start` (such as `@monthly` models if `default_pr_start` was set to `1 week ago`) will be excluded from backfill entirely. | int | N |
298+
| `default_pr_preview_start` | Default start when computing [data previews](../concepts/plans.md#data-preview) for forward-only changes in PR environments. Defaults to `yesterday`, independent of `default_pr_start`, so preview data can be limited without reducing the regular PR backfill window. | str | N |
299+
| `pr_preview_min_intervals` | Intended for use when `default_pr_preview_start` is set to a relative time. This ensures that at least this many intervals are included for forward-only previews in the PR environment. Default: `1` | int | N |
298300
| `skip_pr_backfill` | Indicates if the bot should skip backfilling models in the PR environment. Default: `True` | bool | N |
299301
| `pr_include_unmodified` | Indicates whether to include unmodified models in the PR environment. Default to the project's config value (which defaults to `False`) | bool | N |
300302
| `run_on_deploy_to_prod` | Indicates whether to run latest intervals when deploying to prod. If set to false, the deployment will backfill only the changed models up to the existing latest interval in production, ignoring any missing intervals beyond this point. Default: `False` | bool | N |
@@ -320,6 +322,7 @@ Example with all properties defined:
320322
sql: full
321323
seed: full
322324
default_pr_start: "1 week ago"
325+
default_pr_preview_start: "yesterday"
323326
skip_pr_backfill: false
324327
run_on_deploy_to_prod: false
325328
prod_branch_name: production
@@ -344,6 +347,7 @@ Example with all properties defined:
344347
seed=AutoCategorizationMode.FULL,
345348
),
346349
default_pr_start="1 week ago",
350+
default_pr_preview_start="yesterday",
347351
skip_pr_backfill=False,
348352
run_on_deploy_to_prod=False,
349353
prod_branch_name="production",

sqlmesh/core/context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,6 +1469,8 @@ def plan_builder(
14691469
backfill_models: t.Optional[t.Collection[str]] = None,
14701470
categorizer_config: t.Optional[CategorizerConfig] = None,
14711471
enable_preview: t.Optional[bool] = None,
1472+
preview_start: t.Optional[TimeLike] = None,
1473+
preview_min_intervals: t.Optional[int] = None,
14721474
run: t.Optional[bool] = None,
14731475
diff_rendered: t.Optional[bool] = None,
14741476
skip_linter: t.Optional[bool] = None,
@@ -1510,6 +1512,8 @@ def plan_builder(
15101512
select_models: A list of model selection strings to filter the models that should be included into this plan.
15111513
backfill_models: A list of model selection strings to filter the models for which the data should be backfilled.
15121514
enable_preview: Indicates whether to enable preview for forward-only models in development environments.
1515+
preview_start: The start date for forward-only previews.
1516+
preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot.
15131517
run: Whether to run latest intervals as part of the plan application.
15141518
diff_rendered: Whether the diff should compare raw vs rendered models
15151519
min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered
@@ -1543,6 +1547,8 @@ def plan_builder(
15431547
"select_models": list(select_models) if select_models is not None else None,
15441548
"backfill_models": list(backfill_models) if backfill_models is not None else None,
15451549
"enable_preview": enable_preview,
1550+
"preview_start": preview_start,
1551+
"preview_min_intervals": preview_min_intervals,
15461552
"run": run,
15471553
"diff_rendered": diff_rendered,
15481554
"skip_linter": skip_linter,
@@ -1744,6 +1750,8 @@ def plan_builder(
17441750
enable_preview=(
17451751
enable_preview if enable_preview is not None else self._plan_preview_enabled
17461752
),
1753+
preview_start=preview_start,
1754+
preview_min_intervals=preview_min_intervals or 0,
17471755
end_bounded=not run,
17481756
ensure_finalized_snapshots=self.config.plan.use_finalized_state,
17491757
start_override_per_model=start_override_per_model,

sqlmesh/core/plan/builder.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class PlanBuilder:
8787
default_start: The default plan start to use if not specified.
8888
default_end: The default plan end to use if not specified.
8989
enable_preview: Whether to enable preview for forward-only models in development environments.
90+
preview_start: The start time to use for forward-only previews. Defaults to the plan start.
91+
preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot.
9092
end_bounded: If set to true, the missing intervals will be bounded by the target end date, disregarding lookback,
9193
allow_partials, and other attributes that could cause the intervals to exceed the target end date.
9294
ensure_finalized_snapshots: Whether to compare against snapshots from the latest finalized
@@ -125,6 +127,8 @@ def __init__(
125127
default_start: t.Optional[TimeLike] = None,
126128
default_end: t.Optional[TimeLike] = None,
127129
enable_preview: bool = False,
130+
preview_start: t.Optional[TimeLike] = None,
131+
preview_min_intervals: int = 0,
128132
end_bounded: bool = False,
129133
ensure_finalized_snapshots: bool = False,
130134
explain: bool = False,
@@ -148,6 +152,8 @@ def __init__(
148152
allow_additive_models if allow_additive_models is not None else []
149153
)
150154
self._enable_preview = enable_preview
155+
self._preview_start = preview_start
156+
self._preview_min_intervals = preview_min_intervals
151157
self._end_bounded = end_bounded
152158
self._ensure_finalized_snapshots = ensure_finalized_snapshots
153159
self._ignore_cron = ignore_cron
@@ -447,9 +453,12 @@ def _build_restatements(
447453
possible_intervals = {
448454
restatements[p.snapshot_id] for p in restating_parents if p.is_incremental
449455
}
456+
removal_start = (
457+
self._forward_only_preview_start(snapshot, start, end) if is_preview else start
458+
)
450459
possible_intervals.add(
451460
snapshot.get_removal_interval(
452-
start,
461+
removal_start,
453462
end,
454463
self._execution_time,
455464
strict=False,
@@ -474,6 +483,21 @@ def _build_restatements(
474483

475484
return restatements
476485

486+
def _forward_only_preview_start(
487+
self, snapshot: Snapshot, default_start: TimeLike, end: TimeLike
488+
) -> TimeLike:
489+
preview_start = self._preview_start or default_start
490+
if not self._preview_min_intervals:
491+
return preview_start
492+
493+
relative_base = to_datetime(self.execution_time)
494+
preview_end = to_datetime(end, relative_base=relative_base)
495+
min_start = snapshot.node.cron_floor(preview_end)
496+
for _ in range(self._preview_min_intervals):
497+
min_start = snapshot.node.cron_prev(min_start)
498+
499+
return min(to_datetime(preview_start, relative_base=relative_base), min_start)
500+
477501
def _build_directly_and_indirectly_modified(
478502
self, dag: DAG[SnapshotId]
479503
) -> t.Tuple[t.Set[SnapshotId], SnapshotMapping]:

sqlmesh/integrations/github/cicd/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ class GithubCICDBotConfig(BaseConfig):
2727
default=None, alias="auto_categorize_changes"
2828
)
2929
default_pr_start: t.Optional[TimeLike] = None
30+
default_pr_preview_start: TimeLike = "yesterday"
3031
skip_pr_backfill_: t.Optional[bool] = Field(default=None, alias="skip_pr_backfill")
3132
pr_include_unmodified_: t.Optional[bool] = Field(default=None, alias="pr_include_unmodified")
3233
run_on_deploy_to_prod: bool = False
3334
pr_environment_name: t.Optional[str] = None
3435
pr_min_intervals: t.Optional[int] = None
36+
pr_preview_min_intervals: int = Field(default=1, ge=0)
3537
prod_branch_names_: t.Optional[str] = Field(default=None, alias="prod_branch_name")
3638
forward_only_branch_suffix_: t.Optional[str] = Field(
3739
default=None, alias="forward_only_branch_suffix"
@@ -88,9 +90,11 @@ def forward_only_branch_suffix(self) -> str:
8890
"command_namespace",
8991
"auto_categorize_changes",
9092
"default_pr_start",
93+
"default_pr_preview_start",
9194
"skip_pr_backfill",
9295
"pr_include_unmodified",
9396
"run_on_deploy_to_prod",
9497
"pr_min_intervals",
98+
"pr_preview_min_intervals",
9599
"forward_only_branch_suffix",
96100
}

sqlmesh/integrations/github/cicd/controller.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,8 @@ def pr_plan(self) -> Plan:
404404
categorizer_config=self.bot_config.auto_categorize_changes,
405405
start=self.bot_config.default_pr_start,
406406
min_intervals=self.bot_config.pr_min_intervals,
407+
preview_start=self.bot_config.default_pr_preview_start,
408+
preview_min_intervals=self.bot_config.pr_preview_min_intervals,
407409
skip_backfill=self.bot_config.skip_pr_backfill,
408410
include_unmodified=self.bot_config.pr_include_unmodified,
409411
forward_only=self.forward_only_plan,

tests/core/test_plan.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2934,6 +2934,122 @@ def test_unaligned_start_model_with_forward_only_preview(make_snapshot):
29342934
assert not plan.deployability_index.is_deployable(snapshot_b)
29352935

29362936

2937+
def _make_forward_only_preview_context_diff(make_snapshot):
2938+
old_snapshot = make_snapshot(
2939+
SqlModel(
2940+
name="a",
2941+
query=parse_one("select 1, ds"),
2942+
kind=dict(
2943+
name=ModelKindName.INCREMENTAL_BY_TIME_RANGE,
2944+
forward_only=True,
2945+
time_column="ds",
2946+
),
2947+
start="2025-01-01",
2948+
)
2949+
)
2950+
old_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
2951+
2952+
new_snapshot = make_snapshot(
2953+
SqlModel(
2954+
name="a",
2955+
query=parse_one("select 2, ds"),
2956+
kind=dict(
2957+
name=ModelKindName.INCREMENTAL_BY_TIME_RANGE,
2958+
forward_only=True,
2959+
time_column="ds",
2960+
),
2961+
start="2025-01-01",
2962+
)
2963+
)
2964+
new_snapshot.previous_versions = old_snapshot.all_versions
2965+
2966+
context_diff = ContextDiff(
2967+
environment="test_environment",
2968+
is_new_environment=True,
2969+
is_unfinalized_environment=False,
2970+
normalize_environment_name=True,
2971+
create_from="prod",
2972+
create_from_env_exists=True,
2973+
added=set(),
2974+
removed_snapshots={},
2975+
snapshots={new_snapshot.snapshot_id: new_snapshot},
2976+
new_snapshots={new_snapshot.snapshot_id: new_snapshot},
2977+
modified_snapshots={old_snapshot.name: (new_snapshot, old_snapshot)},
2978+
previous_plan_id=None,
2979+
previously_promoted_snapshot_ids=set(),
2980+
previous_finalized_snapshots=None,
2981+
previous_gateway_managed_virtual_layer=False,
2982+
gateway_managed_virtual_layer=False,
2983+
environment_statements=[],
2984+
)
2985+
2986+
return context_diff, new_snapshot
2987+
2988+
2989+
@time_machine.travel("2026-06-02 00:00:00 UTC")
2990+
def test_forward_only_preview_uses_preview_start(make_snapshot):
2991+
context_diff, new_snapshot = _make_forward_only_preview_context_diff(make_snapshot)
2992+
2993+
plan = PlanBuilder(
2994+
context_diff,
2995+
start="2025-01-01",
2996+
preview_start="yesterday",
2997+
preview_min_intervals=1,
2998+
enable_preview=True,
2999+
is_dev=True,
3000+
).build()
3001+
3002+
assert plan.start == "2025-01-01"
3003+
assert plan.restatements == {
3004+
new_snapshot.snapshot_id: (
3005+
to_timestamp("2026-06-01"),
3006+
to_timestamp("2026-06-02"),
3007+
)
3008+
}
3009+
3010+
3011+
@time_machine.travel("2026-06-02 00:00:00 UTC")
3012+
def test_forward_only_preview_min_intervals_expands_preview_start(make_snapshot):
3013+
context_diff, new_snapshot = _make_forward_only_preview_context_diff(make_snapshot)
3014+
3015+
plan = PlanBuilder(
3016+
context_diff,
3017+
start="2025-01-01",
3018+
preview_start="now",
3019+
preview_min_intervals=1,
3020+
enable_preview=True,
3021+
is_dev=True,
3022+
).build()
3023+
3024+
assert plan.restatements == {
3025+
new_snapshot.snapshot_id: (
3026+
to_timestamp("2026-06-01"),
3027+
to_timestamp("2026-06-02"),
3028+
)
3029+
}
3030+
3031+
3032+
@time_machine.travel("2026-06-02 00:00:00 UTC")
3033+
def test_forward_only_preview_start_can_exceed_preview_min_intervals(make_snapshot):
3034+
context_diff, new_snapshot = _make_forward_only_preview_context_diff(make_snapshot)
3035+
3036+
plan = PlanBuilder(
3037+
context_diff,
3038+
start="2025-01-01",
3039+
preview_start="2025-01-01",
3040+
preview_min_intervals=1,
3041+
enable_preview=True,
3042+
is_dev=True,
3043+
).build()
3044+
3045+
assert plan.restatements == {
3046+
new_snapshot.snapshot_id: (
3047+
to_timestamp("2025-01-01"),
3048+
to_timestamp("2026-06-02"),
3049+
)
3050+
}
3051+
3052+
29373053
def test_restate_production_model_in_dev(make_snapshot, mocker: MockerFixture):
29383054
snapshot = make_snapshot(
29393055
SqlModel(

0 commit comments

Comments
 (0)