sqlmesh 0.213.1.dev1__py3-none-any.whl → 0.227.2.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. sqlmesh/__init__.py +12 -2
  2. sqlmesh/_version.py +2 -2
  3. sqlmesh/cli/main.py +0 -44
  4. sqlmesh/cli/project_init.py +11 -2
  5. sqlmesh/core/_typing.py +1 -0
  6. sqlmesh/core/audit/definition.py +8 -2
  7. sqlmesh/core/config/__init__.py +1 -1
  8. sqlmesh/core/config/connection.py +17 -5
  9. sqlmesh/core/config/dbt.py +13 -0
  10. sqlmesh/core/config/janitor.py +12 -0
  11. sqlmesh/core/config/loader.py +7 -0
  12. sqlmesh/core/config/model.py +2 -0
  13. sqlmesh/core/config/root.py +3 -0
  14. sqlmesh/core/console.py +81 -3
  15. sqlmesh/core/constants.py +1 -1
  16. sqlmesh/core/context.py +69 -26
  17. sqlmesh/core/dialect.py +3 -0
  18. sqlmesh/core/engine_adapter/_typing.py +2 -0
  19. sqlmesh/core/engine_adapter/base.py +322 -22
  20. sqlmesh/core/engine_adapter/base_postgres.py +17 -1
  21. sqlmesh/core/engine_adapter/bigquery.py +146 -7
  22. sqlmesh/core/engine_adapter/clickhouse.py +17 -13
  23. sqlmesh/core/engine_adapter/databricks.py +33 -2
  24. sqlmesh/core/engine_adapter/fabric.py +10 -29
  25. sqlmesh/core/engine_adapter/mixins.py +142 -48
  26. sqlmesh/core/engine_adapter/mssql.py +15 -4
  27. sqlmesh/core/engine_adapter/mysql.py +2 -2
  28. sqlmesh/core/engine_adapter/postgres.py +9 -3
  29. sqlmesh/core/engine_adapter/redshift.py +4 -0
  30. sqlmesh/core/engine_adapter/risingwave.py +1 -0
  31. sqlmesh/core/engine_adapter/shared.py +6 -0
  32. sqlmesh/core/engine_adapter/snowflake.py +82 -11
  33. sqlmesh/core/engine_adapter/spark.py +14 -10
  34. sqlmesh/core/engine_adapter/trino.py +4 -2
  35. sqlmesh/core/environment.py +2 -0
  36. sqlmesh/core/janitor.py +181 -0
  37. sqlmesh/core/lineage.py +1 -0
  38. sqlmesh/core/linter/definition.py +13 -13
  39. sqlmesh/core/linter/rules/builtin.py +29 -0
  40. sqlmesh/core/macros.py +35 -13
  41. sqlmesh/core/model/common.py +2 -0
  42. sqlmesh/core/model/definition.py +82 -28
  43. sqlmesh/core/model/kind.py +66 -2
  44. sqlmesh/core/model/meta.py +108 -4
  45. sqlmesh/core/node.py +101 -1
  46. sqlmesh/core/plan/builder.py +18 -10
  47. sqlmesh/core/plan/common.py +199 -2
  48. sqlmesh/core/plan/definition.py +25 -6
  49. sqlmesh/core/plan/evaluator.py +75 -113
  50. sqlmesh/core/plan/explainer.py +90 -8
  51. sqlmesh/core/plan/stages.py +42 -21
  52. sqlmesh/core/renderer.py +78 -32
  53. sqlmesh/core/scheduler.py +102 -22
  54. sqlmesh/core/selector.py +137 -9
  55. sqlmesh/core/signal.py +64 -1
  56. sqlmesh/core/snapshot/__init__.py +2 -0
  57. sqlmesh/core/snapshot/definition.py +146 -34
  58. sqlmesh/core/snapshot/evaluator.py +689 -124
  59. sqlmesh/core/state_sync/__init__.py +0 -1
  60. sqlmesh/core/state_sync/base.py +55 -33
  61. sqlmesh/core/state_sync/cache.py +12 -7
  62. sqlmesh/core/state_sync/common.py +216 -111
  63. sqlmesh/core/state_sync/db/environment.py +6 -4
  64. sqlmesh/core/state_sync/db/facade.py +42 -24
  65. sqlmesh/core/state_sync/db/interval.py +27 -7
  66. sqlmesh/core/state_sync/db/migrator.py +34 -16
  67. sqlmesh/core/state_sync/db/snapshot.py +177 -169
  68. sqlmesh/core/table_diff.py +2 -2
  69. sqlmesh/core/test/context.py +2 -0
  70. sqlmesh/core/test/definition.py +14 -9
  71. sqlmesh/dbt/adapter.py +22 -16
  72. sqlmesh/dbt/basemodel.py +75 -56
  73. sqlmesh/dbt/builtin.py +116 -12
  74. sqlmesh/dbt/column.py +17 -5
  75. sqlmesh/dbt/common.py +19 -5
  76. sqlmesh/dbt/context.py +14 -1
  77. sqlmesh/dbt/loader.py +61 -9
  78. sqlmesh/dbt/manifest.py +174 -16
  79. sqlmesh/dbt/model.py +183 -85
  80. sqlmesh/dbt/package.py +16 -1
  81. sqlmesh/dbt/profile.py +3 -3
  82. sqlmesh/dbt/project.py +12 -7
  83. sqlmesh/dbt/seed.py +6 -1
  84. sqlmesh/dbt/source.py +13 -1
  85. sqlmesh/dbt/target.py +25 -6
  86. sqlmesh/dbt/test.py +36 -5
  87. sqlmesh/migrations/v0000_baseline.py +95 -0
  88. sqlmesh/migrations/v0061_mysql_fix_blob_text_type.py +5 -7
  89. sqlmesh/migrations/v0062_add_model_gateway.py +5 -1
  90. sqlmesh/migrations/v0063_change_signals.py +5 -3
  91. sqlmesh/migrations/v0064_join_when_matched_strings.py +5 -3
  92. sqlmesh/migrations/v0065_add_model_optimize.py +5 -1
  93. sqlmesh/migrations/v0066_add_auto_restatements.py +8 -3
  94. sqlmesh/migrations/v0067_add_tsql_date_full_precision.py +5 -1
  95. sqlmesh/migrations/v0068_include_unrendered_query_in_metadata_hash.py +5 -1
  96. sqlmesh/migrations/v0069_update_dev_table_suffix.py +5 -3
  97. sqlmesh/migrations/v0070_include_grains_in_metadata_hash.py +5 -1
  98. sqlmesh/migrations/v0071_add_dev_version_to_intervals.py +9 -5
  99. sqlmesh/migrations/v0072_add_environment_statements.py +5 -3
  100. sqlmesh/migrations/v0073_remove_symbolic_disable_restatement.py +5 -3
  101. sqlmesh/migrations/v0074_add_partition_by_time_column_property.py +5 -1
  102. sqlmesh/migrations/v0075_remove_validate_query.py +5 -3
  103. sqlmesh/migrations/v0076_add_cron_tz.py +5 -1
  104. sqlmesh/migrations/v0077_fix_column_type_hash_calculation.py +5 -1
  105. sqlmesh/migrations/v0078_warn_if_non_migratable_python_env.py +5 -3
  106. sqlmesh/migrations/v0079_add_gateway_managed_property.py +10 -5
  107. sqlmesh/migrations/v0080_add_batch_size_to_scd_type_2_models.py +5 -1
  108. sqlmesh/migrations/v0081_update_partitioned_by.py +5 -3
  109. sqlmesh/migrations/v0082_warn_if_incorrectly_duplicated_statements.py +5 -3
  110. sqlmesh/migrations/v0083_use_sql_for_scd_time_data_type_data_hash.py +5 -1
  111. sqlmesh/migrations/v0084_normalize_quote_when_matched_and_merge_filter.py +5 -1
  112. sqlmesh/migrations/v0085_deterministic_repr.py +5 -3
  113. sqlmesh/migrations/v0086_check_deterministic_bug.py +5 -3
  114. sqlmesh/migrations/v0087_normalize_blueprint_variables.py +5 -3
  115. sqlmesh/migrations/v0088_warn_about_variable_python_env_diffs.py +5 -3
  116. sqlmesh/migrations/v0089_add_virtual_environment_mode.py +5 -1
  117. sqlmesh/migrations/v0090_add_forward_only_column.py +9 -5
  118. sqlmesh/migrations/v0091_on_additive_change.py +5 -1
  119. sqlmesh/migrations/v0092_warn_about_dbt_data_type_diff.py +5 -3
  120. sqlmesh/migrations/v0093_use_raw_sql_in_fingerprint.py +5 -1
  121. sqlmesh/migrations/v0094_add_dev_version_and_fingerprint_columns.py +123 -0
  122. sqlmesh/migrations/v0095_warn_about_dbt_raw_sql_diff.py +49 -0
  123. sqlmesh/migrations/v0096_remove_plan_dags_table.py +13 -0
  124. sqlmesh/migrations/v0097_add_dbt_name_in_node.py +9 -0
  125. sqlmesh/migrations/{v0060_move_audits_to_model.py → v0098_add_dbt_node_info_in_node.py} +33 -16
  126. sqlmesh/migrations/v0099_add_last_altered_to_intervals.py +25 -0
  127. sqlmesh/migrations/v0100_add_grants_and_grants_target_layer.py +9 -0
  128. sqlmesh/utils/__init__.py +8 -1
  129. sqlmesh/utils/cache.py +5 -1
  130. sqlmesh/utils/connection_pool.py +2 -1
  131. sqlmesh/utils/dag.py +65 -10
  132. sqlmesh/utils/date.py +8 -1
  133. sqlmesh/utils/errors.py +8 -0
  134. sqlmesh/utils/jinja.py +54 -4
  135. sqlmesh/utils/pydantic.py +6 -6
  136. sqlmesh/utils/windows.py +13 -3
  137. {sqlmesh-0.213.1.dev1.dist-info → sqlmesh-0.227.2.dev4.dist-info}/METADATA +7 -10
  138. sqlmesh-0.227.2.dev4.dist-info/RECORD +370 -0
  139. sqlmesh_dbt/cli.py +70 -7
  140. sqlmesh_dbt/console.py +14 -6
  141. sqlmesh_dbt/operations.py +103 -24
  142. sqlmesh_dbt/selectors.py +39 -1
  143. web/client/dist/assets/{Audits-Ucsx1GzF.js → Audits-CBiYyyx-.js} +1 -1
  144. web/client/dist/assets/{Banner-BWDzvavM.js → Banner-DSRbUlO5.js} +1 -1
  145. web/client/dist/assets/{ChevronDownIcon-D2VL13Ah.js → ChevronDownIcon-MK_nrjD_.js} +1 -1
  146. web/client/dist/assets/{ChevronRightIcon-DWGYbf1l.js → ChevronRightIcon-CLWtT22Q.js} +1 -1
  147. web/client/dist/assets/{Content-DdHDZM3I.js → Content-BNuGZN5l.js} +1 -1
  148. web/client/dist/assets/{Content-Bikfy8fh.js → Content-CSHJyW0n.js} +1 -1
  149. web/client/dist/assets/{Data-CzAJH7rW.js → Data-C1oRDbLx.js} +1 -1
  150. web/client/dist/assets/{DataCatalog-BJF11g8f.js → DataCatalog-HXyX2-_j.js} +1 -1
  151. web/client/dist/assets/{Editor-s0SBpV2y.js → Editor-BDyfpUuw.js} +1 -1
  152. web/client/dist/assets/{Editor-DgLhgKnm.js → Editor-D0jNItwC.js} +1 -1
  153. web/client/dist/assets/{Errors-D0m0O1d3.js → Errors-BfuFLcPi.js} +1 -1
  154. web/client/dist/assets/{FileExplorer-CEv0vXkt.js → FileExplorer-BR9IE3he.js} +1 -1
  155. web/client/dist/assets/{Footer-BwzXn8Ew.js → Footer-CgBEtiAh.js} +1 -1
  156. web/client/dist/assets/{Header-6heDkEqG.js → Header-DSqR6nSO.js} +1 -1
  157. web/client/dist/assets/{Input-obuJsD6k.js → Input-B-oZ6fGO.js} +1 -1
  158. web/client/dist/assets/Lineage-DYQVwDbD.js +1 -0
  159. web/client/dist/assets/{ListboxShow-HM9_qyrt.js → ListboxShow-BE5-xevs.js} +1 -1
  160. web/client/dist/assets/{ModelLineage-zWdKo0U2.js → ModelLineage-DkIFAYo4.js} +1 -1
  161. web/client/dist/assets/{Models-Bcu66SRz.js → Models-D5dWr8RB.js} +1 -1
  162. web/client/dist/assets/{Page-BWEEQfIt.js → Page-C-XfU5BR.js} +1 -1
  163. web/client/dist/assets/{Plan-C4gXCqlf.js → Plan-ZEuTINBq.js} +1 -1
  164. web/client/dist/assets/{PlusCircleIcon-CVDO651q.js → PlusCircleIcon-DVXAHG8_.js} +1 -1
  165. web/client/dist/assets/{ReportErrors-BT6xFwAr.js → ReportErrors-B7FEPzMB.js} +1 -1
  166. web/client/dist/assets/{Root-ryJoBK4h.js → Root-8aZyhPxF.js} +1 -1
  167. web/client/dist/assets/{SearchList-DB04sPb9.js → SearchList-W_iT2G82.js} +1 -1
  168. web/client/dist/assets/{SelectEnvironment-CUYcXUu6.js → SelectEnvironment-C65jALmO.js} +1 -1
  169. web/client/dist/assets/{SourceList-Doo_9ZGp.js → SourceList-DSLO6nVJ.js} +1 -1
  170. web/client/dist/assets/{SourceListItem-D5Mj7Dly.js → SourceListItem-BHt8d9-I.js} +1 -1
  171. web/client/dist/assets/{SplitPane-qHmkD1qy.js → SplitPane-CViaZmw6.js} +1 -1
  172. web/client/dist/assets/{Tests-DH1Z74ML.js → Tests-DhaVt5t1.js} +1 -1
  173. web/client/dist/assets/{Welcome-DqUJUNMF.js → Welcome-DvpjH-_4.js} +1 -1
  174. web/client/dist/assets/context-BctCsyGb.js +71 -0
  175. web/client/dist/assets/{context-Dr54UHLi.js → context-DFNeGsFF.js} +1 -1
  176. web/client/dist/assets/{editor-DYIP1yQ4.js → editor-CcO28cqd.js} +1 -1
  177. web/client/dist/assets/{file-DarlIDVi.js → file-CvJN3aZO.js} +1 -1
  178. web/client/dist/assets/{floating-ui.react-dom-BH3TFvkM.js → floating-ui.react-dom-CjE-JNW1.js} +1 -1
  179. web/client/dist/assets/{help-Bl8wqaQc.js → help-DuPhjipa.js} +1 -1
  180. web/client/dist/assets/{index-D1sR7wpN.js → index-C-dJH7yZ.js} +1 -1
  181. web/client/dist/assets/{index-O3mjYpnE.js → index-Dj0i1-CA.js} +2 -2
  182. web/client/dist/assets/{plan-CehRrJUG.js → plan-BTRSbjKn.js} +1 -1
  183. web/client/dist/assets/{popover-CqgMRE0G.js → popover-_Sf0yvOI.js} +1 -1
  184. web/client/dist/assets/{project-6gxepOhm.js → project-BvSOI8MY.js} +1 -1
  185. web/client/dist/index.html +1 -1
  186. sqlmesh/integrations/llm.py +0 -56
  187. sqlmesh/migrations/v0001_init.py +0 -60
  188. sqlmesh/migrations/v0002_remove_identify.py +0 -5
  189. sqlmesh/migrations/v0003_move_batch_size.py +0 -34
  190. sqlmesh/migrations/v0004_environmnent_add_finalized_at.py +0 -23
  191. sqlmesh/migrations/v0005_create_seed_table.py +0 -24
  192. sqlmesh/migrations/v0006_change_seed_hash.py +0 -5
  193. sqlmesh/migrations/v0007_env_table_info_to_kind.py +0 -99
  194. sqlmesh/migrations/v0008_create_intervals_table.py +0 -38
  195. sqlmesh/migrations/v0009_remove_pre_post_hooks.py +0 -62
  196. sqlmesh/migrations/v0010_seed_hash_batch_size.py +0 -5
  197. sqlmesh/migrations/v0011_add_model_kind_name.py +0 -63
  198. sqlmesh/migrations/v0012_update_jinja_expressions.py +0 -86
  199. sqlmesh/migrations/v0013_serde_using_model_dialects.py +0 -87
  200. sqlmesh/migrations/v0014_fix_dev_intervals.py +0 -14
  201. sqlmesh/migrations/v0015_environment_add_promoted_snapshot_ids.py +0 -26
  202. sqlmesh/migrations/v0016_fix_windows_path.py +0 -59
  203. sqlmesh/migrations/v0017_fix_windows_seed_path.py +0 -55
  204. sqlmesh/migrations/v0018_rename_snapshot_model_to_node.py +0 -53
  205. sqlmesh/migrations/v0019_add_env_suffix_target.py +0 -28
  206. sqlmesh/migrations/v0020_remove_redundant_attributes_from_dbt_models.py +0 -80
  207. sqlmesh/migrations/v0021_fix_table_properties.py +0 -62
  208. sqlmesh/migrations/v0022_move_project_to_model.py +0 -54
  209. sqlmesh/migrations/v0023_fix_added_models_with_forward_only_parents.py +0 -65
  210. sqlmesh/migrations/v0024_replace_model_kind_name_enum_with_value.py +0 -55
  211. sqlmesh/migrations/v0025_fix_intervals_and_missing_change_category.py +0 -117
  212. sqlmesh/migrations/v0026_remove_dialect_from_seed.py +0 -55
  213. sqlmesh/migrations/v0027_minute_interval_to_five.py +0 -57
  214. sqlmesh/migrations/v0028_add_plan_dags_table.py +0 -29
  215. sqlmesh/migrations/v0029_generate_schema_types_using_dialect.py +0 -69
  216. sqlmesh/migrations/v0030_update_unrestorable_snapshots.py +0 -65
  217. sqlmesh/migrations/v0031_remove_dbt_target_fields.py +0 -65
  218. sqlmesh/migrations/v0032_add_sqlmesh_version.py +0 -25
  219. sqlmesh/migrations/v0033_mysql_fix_blob_text_type.py +0 -45
  220. sqlmesh/migrations/v0034_add_default_catalog.py +0 -367
  221. sqlmesh/migrations/v0035_add_catalog_name_override.py +0 -22
  222. sqlmesh/migrations/v0036_delete_plan_dags_bug_fix.py +0 -14
  223. sqlmesh/migrations/v0037_remove_dbt_is_incremental_macro.py +0 -61
  224. sqlmesh/migrations/v0038_add_expiration_ts_to_snapshot.py +0 -73
  225. sqlmesh/migrations/v0039_include_environment_in_plan_dag_spec.py +0 -68
  226. sqlmesh/migrations/v0040_add_previous_finalized_snapshots.py +0 -26
  227. sqlmesh/migrations/v0041_remove_hash_raw_query_attribute.py +0 -59
  228. sqlmesh/migrations/v0042_trim_indirect_versions.py +0 -66
  229. sqlmesh/migrations/v0043_fix_remove_obsolete_attributes_in_plan_dags.py +0 -61
  230. sqlmesh/migrations/v0044_quote_identifiers_in_model_attributes.py +0 -5
  231. sqlmesh/migrations/v0045_move_gateway_variable.py +0 -70
  232. sqlmesh/migrations/v0046_add_batch_concurrency.py +0 -8
  233. sqlmesh/migrations/v0047_change_scd_string_to_column.py +0 -5
  234. sqlmesh/migrations/v0048_drop_indirect_versions.py +0 -59
  235. sqlmesh/migrations/v0049_replace_identifier_with_version_in_seeds_table.py +0 -57
  236. sqlmesh/migrations/v0050_drop_seeds_table.py +0 -11
  237. sqlmesh/migrations/v0051_rename_column_descriptions.py +0 -65
  238. sqlmesh/migrations/v0052_add_normalize_name_in_environment_naming_info.py +0 -28
  239. sqlmesh/migrations/v0053_custom_model_kind_extra_attributes.py +0 -5
  240. sqlmesh/migrations/v0054_fix_trailing_comments.py +0 -5
  241. sqlmesh/migrations/v0055_add_updated_ts_unpaused_ts_ttl_ms_unrestorable_to_snapshot.py +0 -132
  242. sqlmesh/migrations/v0056_restore_table_indexes.py +0 -118
  243. sqlmesh/migrations/v0057_add_table_format.py +0 -5
  244. sqlmesh/migrations/v0058_add_requirements.py +0 -26
  245. sqlmesh/migrations/v0059_add_physical_version.py +0 -5
  246. sqlmesh-0.213.1.dev1.dist-info/RECORD +0 -421
  247. web/client/dist/assets/Lineage-D0Hgdz2v.js +0 -1
  248. web/client/dist/assets/context-DgX0fp2E.js +0 -68
  249. {sqlmesh-0.213.1.dev1.dist-info → sqlmesh-0.227.2.dev4.dist-info}/WHEEL +0 -0
  250. {sqlmesh-0.213.1.dev1.dist-info → sqlmesh-0.227.2.dev4.dist-info}/entry_points.txt +0 -0
  251. {sqlmesh-0.213.1.dev1.dist-info → sqlmesh-0.227.2.dev4.dist-info}/licenses/LICENSE +0 -0
  252. {sqlmesh-0.213.1.dev1.dist-info → sqlmesh-0.227.2.dev4.dist-info}/top_level.txt +0 -0
@@ -58,7 +58,18 @@ class Plan(PydanticModel, frozen=True):
58
58
  indirectly_modified: t.Dict[SnapshotId, t.Set[SnapshotId]]
59
59
 
60
60
  deployability_index: DeployabilityIndex
61
+ selected_models_to_restate: t.Optional[t.Set[str]] = None
62
+ """Models that have been explicitly selected for restatement by a user"""
61
63
  restatements: t.Dict[SnapshotId, Interval]
64
+ """
65
+ All models being restated, which are typically the explicitly selected ones + their downstream dependencies.
66
+
67
+ Note that dev previews are also considered restatements, so :selected_models_to_restate can be empty
68
+ while :restatements is still populated with dev previews
69
+ """
70
+ restate_all_snapshots: bool
71
+ """Whether or not to clear intervals from state for other versions of the models listed in :restatements"""
72
+
62
73
  start_override_per_model: t.Optional[t.Dict[str, datetime]]
63
74
  end_override_per_model: t.Optional[t.Dict[str, datetime]]
64
75
 
@@ -70,6 +81,8 @@ class Plan(PydanticModel, frozen=True):
70
81
  execution_time_: t.Optional[TimeLike] = Field(default=None, alias="execution_time")
71
82
 
72
83
  user_provided_flags: t.Optional[t.Dict[str, UserProvidedFlags]] = None
84
+ selected_models: t.Optional[t.Set[str]] = None
85
+ """Models that have been selected for this plan (used for dbt selected_resources)"""
73
86
 
74
87
  @cached_property
75
88
  def start(self) -> TimeLike:
@@ -200,8 +213,8 @@ class Plan(PydanticModel, frozen=True):
200
213
 
201
214
  snapshots_by_name = self.context_diff.snapshots_by_name
202
215
  snapshots = [s.table_info for s in self.snapshots.values()]
203
- promoted_snapshot_ids = None
204
- if self.is_dev and not self.include_unmodified:
216
+ promotable_snapshot_ids = None
217
+ if self.is_dev:
205
218
  if self.selected_models_to_backfill is not None:
206
219
  # Only promote models that have been explicitly selected for backfill.
207
220
  promotable_snapshot_ids = {
@@ -212,12 +225,14 @@ class Plan(PydanticModel, frozen=True):
212
225
  if m in snapshots_by_name
213
226
  ],
214
227
  }
215
- else:
228
+ elif not self.include_unmodified:
216
229
  promotable_snapshot_ids = self.context_diff.promotable_snapshot_ids.copy()
217
230
 
218
- promoted_snapshot_ids = [
219
- s.snapshot_id for s in snapshots if s.snapshot_id in promotable_snapshot_ids
220
- ]
231
+ promoted_snapshot_ids = (
232
+ [s.snapshot_id for s in snapshots if s.snapshot_id in promotable_snapshot_ids]
233
+ if promotable_snapshot_ids is not None
234
+ else None
235
+ )
221
236
 
222
237
  previous_finalized_snapshots = (
223
238
  self.context_diff.environment_snapshots
@@ -257,6 +272,7 @@ class Plan(PydanticModel, frozen=True):
257
272
  skip_backfill=self.skip_backfill,
258
273
  empty_backfill=self.empty_backfill,
259
274
  restatements={s.name: i for s, i in self.restatements.items()},
275
+ restate_all_snapshots=self.restate_all_snapshots,
260
276
  is_dev=self.is_dev,
261
277
  allow_destructive_models=self.allow_destructive_models,
262
278
  allow_additive_models=self.allow_additive_models,
@@ -282,6 +298,7 @@ class Plan(PydanticModel, frozen=True):
282
298
  },
283
299
  environment_statements=self.context_diff.environment_statements,
284
300
  user_provided_flags=self.user_provided_flags,
301
+ selected_models=self.selected_models,
285
302
  )
286
303
 
287
304
  @cached_property
@@ -300,6 +317,7 @@ class EvaluatablePlan(PydanticModel):
300
317
  skip_backfill: bool
301
318
  empty_backfill: bool
302
319
  restatements: t.Dict[str, Interval]
320
+ restate_all_snapshots: bool
303
321
  is_dev: bool
304
322
  allow_destructive_models: t.Set[str]
305
323
  allow_additive_models: t.Set[str]
@@ -319,6 +337,7 @@ class EvaluatablePlan(PydanticModel):
319
337
  disabled_restatement_models: t.Set[str]
320
338
  environment_statements: t.Optional[t.List[EnvironmentStatements]] = None
321
339
  user_provided_flags: t.Optional[t.Dict[str, UserProvidedFlags]] = None
340
+ selected_models: t.Optional[t.Set[str]] = None
322
341
 
323
342
  def is_selected_for_backfill(self, model_fqn: str) -> bool:
324
343
  return self.models_to_backfill is None or model_fqn in self.models_to_backfill
@@ -22,7 +22,7 @@ from sqlmesh.core import constants as c
22
22
  from sqlmesh.core.console import Console, get_console
23
23
  from sqlmesh.core.environment import EnvironmentNamingInfo, execute_environment_statements
24
24
  from sqlmesh.core.macros import RuntimeStage
25
- from sqlmesh.core.snapshot.definition import Interval, to_view_mapping
25
+ from sqlmesh.core.snapshot.definition import to_view_mapping, SnapshotTableInfo
26
26
  from sqlmesh.core.plan import stages
27
27
  from sqlmesh.core.plan.definition import EvaluatablePlan
28
28
  from sqlmesh.core.scheduler import Scheduler
@@ -33,17 +33,15 @@ from sqlmesh.core.snapshot import (
33
33
  SnapshotIntervals,
34
34
  SnapshotId,
35
35
  SnapshotInfoLike,
36
- SnapshotTableInfo,
37
36
  SnapshotCreationFailedError,
38
- SnapshotNameVersion,
39
37
  )
40
38
  from sqlmesh.utils import to_snake_case
41
39
  from sqlmesh.core.state_sync import StateSync
40
+ from sqlmesh.core.plan.common import identify_restatement_intervals_across_snapshot_versions
42
41
  from sqlmesh.utils import CorrelationId
43
42
  from sqlmesh.utils.concurrency import NodeExecutionFailedError
44
- from sqlmesh.utils.errors import PlanError, SQLMeshError
45
- from sqlmesh.utils.dag import DAG
46
- from sqlmesh.utils.date import now
43
+ from sqlmesh.utils.errors import PlanError, ConflictingPlanError, SQLMeshError
44
+ from sqlmesh.utils.date import now, to_timestamp
47
45
 
48
46
  logger = logging.getLogger(__name__)
49
47
 
@@ -137,6 +135,7 @@ class BuiltInPlanEvaluator(PlanEvaluator):
137
135
  start=plan.start,
138
136
  end=plan.end,
139
137
  execution_time=plan.execution_time,
138
+ selected_models=plan.selected_models,
140
139
  )
141
140
 
142
141
  def visit_after_all_stage(self, stage: stages.AfterAllStage, plan: EvaluatablePlan) -> None:
@@ -150,6 +149,7 @@ class BuiltInPlanEvaluator(PlanEvaluator):
150
149
  start=plan.start,
151
150
  end=plan.end,
152
151
  execution_time=plan.execution_time,
152
+ selected_models=plan.selected_models,
153
153
  )
154
154
 
155
155
  def visit_create_snapshot_records_stage(
@@ -257,6 +257,8 @@ class BuiltInPlanEvaluator(PlanEvaluator):
257
257
  allow_destructive_snapshots=plan.allow_destructive_models,
258
258
  allow_additive_snapshots=plan.allow_additive_models,
259
259
  selected_snapshot_ids=stage.selected_snapshot_ids,
260
+ selected_models=plan.selected_models,
261
+ is_restatement=bool(plan.restatements),
260
262
  )
261
263
  if errors:
262
264
  raise PlanError("Plan application failed.")
@@ -286,27 +288,78 @@ class BuiltInPlanEvaluator(PlanEvaluator):
286
288
  def visit_restatement_stage(
287
289
  self, stage: stages.RestatementStage, plan: EvaluatablePlan
288
290
  ) -> None:
289
- snapshot_intervals_to_restate = {(s, i) for s, i in stage.snapshot_intervals.items()}
290
-
291
- # Restating intervals on prod plans should mean that the intervals are cleared across
292
- # all environments, not just the version currently in prod
293
- # This ensures that work done in dev environments can still be promoted to prod
294
- # by forcing dev environments to re-run intervals that changed in prod
291
+ # Restating intervals on prod plans means that once the data for the intervals being restated has been backfilled
292
+ # (which happens in the backfill stage) then we need to clear those intervals *from state* across all other environments.
293
+ #
294
+ # This ensures that work done in dev environments can still be promoted to prod by forcing dev environments to
295
+ # re-run intervals that changed in prod (because after this stage runs they are cleared from state and thus show as missing)
296
+ #
297
+ # It also means that any new dev environments created while this restatement plan was running also get the
298
+ # correct intervals cleared because we look up matching snapshots as at right now and not as at the time the plan
299
+ # was created, which could have been several hours ago if there was a lot of data to restate.
295
300
  #
296
301
  # Without this rule, its possible that promoting a dev table to prod will introduce old data to prod
297
- snapshot_intervals_to_restate.update(
298
- self._restatement_intervals_across_all_environments(
299
- prod_restatements=plan.restatements,
300
- disable_restatement_models=plan.disabled_restatement_models,
301
- loaded_snapshots={s.snapshot_id: s for s in stage.all_snapshots.values()},
302
- )
303
- )
304
302
 
305
- self.state_sync.remove_intervals(
306
- snapshot_intervals=list(snapshot_intervals_to_restate),
307
- remove_shared_versions=plan.is_prod,
303
+ intervals_to_clear = identify_restatement_intervals_across_snapshot_versions(
304
+ state_reader=self.state_sync,
305
+ prod_restatements=plan.restatements,
306
+ disable_restatement_models=plan.disabled_restatement_models,
307
+ loaded_snapshots={s.snapshot_id: s for s in stage.all_snapshots.values()},
308
+ current_ts=to_timestamp(plan.execution_time or now()),
308
309
  )
309
310
 
311
+ if not intervals_to_clear:
312
+ # Nothing to do
313
+ return
314
+
315
+ # While the restatements were being processed, did any of the snapshots being restated get new versions deployed?
316
+ # If they did, they will not reflect the data that just got restated, so we need to notify the user
317
+ deployed_during_restatement: t.Dict[
318
+ str, t.Tuple[SnapshotTableInfo, SnapshotTableInfo]
319
+ ] = {} # tuple of (restated_snapshot, current_prod_snapshot)
320
+
321
+ if deployed_env := self.state_sync.get_environment(plan.environment.name):
322
+ promoted_snapshots_by_name = {s.name: s for s in deployed_env.snapshots}
323
+
324
+ for name in plan.restatements:
325
+ snapshot = stage.all_snapshots[name]
326
+ version = snapshot.table_info.version
327
+ if (
328
+ prod_snapshot := promoted_snapshots_by_name.get(name)
329
+ ) and prod_snapshot.version != version:
330
+ deployed_during_restatement[name] = (
331
+ snapshot.table_info,
332
+ prod_snapshot.table_info,
333
+ )
334
+
335
+ # we need to *not* clear the intervals on the snapshots where new versions were deployed while the restatement was running in order to prevent
336
+ # subsequent plans from having unexpected intervals to backfill.
337
+ # we instead list the affected models and abort the plan with an error so the user can decide what to do
338
+ # (either re-attempt the restatement plan or leave things as they are)
339
+ filtered_intervals_to_clear = [
340
+ (s.snapshot, s.interval)
341
+ for s in intervals_to_clear.values()
342
+ if s.snapshot.name not in deployed_during_restatement
343
+ ]
344
+
345
+ if filtered_intervals_to_clear:
346
+ # We still clear intervals in other envs for models that were successfully restated without having new versions promoted during restatement
347
+ self.state_sync.remove_intervals(
348
+ snapshot_intervals=filtered_intervals_to_clear,
349
+ remove_shared_versions=plan.is_prod,
350
+ )
351
+
352
+ if deployed_env and deployed_during_restatement:
353
+ self.console.log_models_updated_during_restatement(
354
+ list(deployed_during_restatement.values()),
355
+ plan.environment.naming_info,
356
+ self.default_catalog,
357
+ )
358
+ raise ConflictingPlanError(
359
+ f"Another plan ({deployed_env.summary.plan_id}) deployed new versions of {len(deployed_during_restatement)} models in the target environment '{plan.environment.name}' while they were being restated by this plan.\n"
360
+ "Please re-apply your plan if these new versions should be restated."
361
+ )
362
+
310
363
  def visit_environment_record_update_stage(
311
364
  self, stage: stages.EnvironmentRecordUpdateStage, plan: EvaluatablePlan
312
365
  ) -> None:
@@ -419,97 +472,6 @@ class BuiltInPlanEvaluator(PlanEvaluator):
419
472
  on_complete=on_complete,
420
473
  )
421
474
 
422
- def _restatement_intervals_across_all_environments(
423
- self,
424
- prod_restatements: t.Dict[str, Interval],
425
- disable_restatement_models: t.Set[str],
426
- loaded_snapshots: t.Dict[SnapshotId, Snapshot],
427
- ) -> t.Set[t.Tuple[SnapshotTableInfo, Interval]]:
428
- """
429
- Given a map of snapshot names + intervals to restate in prod:
430
- - Look up matching snapshots across all environments (match based on name - regardless of version)
431
- - For each match, also match downstream snapshots while filtering out models that have restatement disabled
432
- - Return all matches mapped to the intervals of the prod snapshot being restated
433
-
434
- The goal here is to produce a list of intervals to invalidate across all environments so that a cadence
435
- run in those environments causes the intervals to be repopulated
436
- """
437
- if not prod_restatements:
438
- return set()
439
-
440
- prod_name_versions: t.Set[SnapshotNameVersion] = {
441
- s.name_version for s in loaded_snapshots.values()
442
- }
443
-
444
- snapshots_to_restate: t.Dict[SnapshotId, t.Tuple[SnapshotTableInfo, Interval]] = {}
445
-
446
- for env_summary in self.state_sync.get_environments_summary():
447
- # Fetch the full environment object one at a time to avoid loading all environments into memory at once
448
- env = self.state_sync.get_environment(env_summary.name)
449
- if not env:
450
- logger.warning("Environment %s not found", env_summary.name)
451
- continue
452
-
453
- keyed_snapshots = {s.name: s.table_info for s in env.snapshots}
454
-
455
- # We dont just restate matching snapshots, we also have to restate anything downstream of them
456
- # so that if A gets restated in prod and dev has A <- B <- C, B and C get restated in dev
457
- env_dag = DAG({s.name: {p.name for p in s.parents} for s in env.snapshots})
458
-
459
- for restatement, intervals in prod_restatements.items():
460
- if restatement not in keyed_snapshots:
461
- continue
462
- affected_snapshot_names = [
463
- x
464
- for x in ([restatement] + env_dag.downstream(restatement))
465
- if x not in disable_restatement_models
466
- ]
467
- snapshots_to_restate.update(
468
- {
469
- keyed_snapshots[a].snapshot_id: (keyed_snapshots[a], intervals)
470
- for a in affected_snapshot_names
471
- # Don't restate a snapshot if it shares the version with a snapshot in prod
472
- if keyed_snapshots[a].name_version not in prod_name_versions
473
- }
474
- )
475
-
476
- # for any affected full_history_restatement_only snapshots, we need to widen the intervals being restated to
477
- # include the whole time range for that snapshot. This requires a call to state to load the full snapshot record,
478
- # so we only do it if necessary
479
- full_history_restatement_snapshot_ids = [
480
- # FIXME: full_history_restatement_only is just one indicator that the snapshot can only be fully refreshed, the other one is Model.depends_on_self
481
- # however, to figure out depends_on_self, we have to render all the model queries which, alongside having to fetch full snapshots from state,
482
- # is problematic in secure environments that are deliberately isolated from arbitrary user code (since rendering a query may require user macros to be present)
483
- # So for now, these are not considered
484
- s_id
485
- for s_id, s in snapshots_to_restate.items()
486
- if s[0].full_history_restatement_only
487
- ]
488
- if full_history_restatement_snapshot_ids:
489
- # only load full snapshot records that we havent already loaded
490
- additional_snapshots = self.state_sync.get_snapshots(
491
- [
492
- s.snapshot_id
493
- for s in full_history_restatement_snapshot_ids
494
- if s.snapshot_id not in loaded_snapshots
495
- ]
496
- )
497
-
498
- all_snapshots = loaded_snapshots | additional_snapshots
499
-
500
- for full_snapshot_id in full_history_restatement_snapshot_ids:
501
- full_snapshot = all_snapshots[full_snapshot_id]
502
- _, original_intervals = snapshots_to_restate[full_snapshot_id]
503
- original_start, original_end = original_intervals
504
-
505
- # get_removal_interval() widens intervals if necessary
506
- new_intervals = full_snapshot.get_removal_interval(
507
- start=original_start, end=original_end
508
- )
509
- snapshots_to_restate[full_snapshot_id] = (full_snapshot.table_info, new_intervals)
510
-
511
- return set(snapshots_to_restate.values())
512
-
513
475
  def _update_intervals_for_new_snapshots(self, snapshots: t.Collection[Snapshot]) -> None:
514
476
  snapshots_intervals: t.List[SnapshotIntervals] = []
515
477
  for snapshot in snapshots:
@@ -1,6 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import abc
2
4
  import typing as t
3
5
  import logging
6
+ from dataclasses import dataclass
7
+ from collections import defaultdict
4
8
 
5
9
  from rich.console import Console as RichConsole
6
10
  from rich.tree import Tree
@@ -8,6 +12,10 @@ from sqlglot.dialects.dialect import DialectType
8
12
  from sqlmesh.core import constants as c
9
13
  from sqlmesh.core.console import Console, TerminalConsole, get_console
10
14
  from sqlmesh.core.environment import EnvironmentNamingInfo
15
+ from sqlmesh.core.plan.common import (
16
+ SnapshotIntervalClearRequest,
17
+ identify_restatement_intervals_across_snapshot_versions,
18
+ )
11
19
  from sqlmesh.core.plan.definition import EvaluatablePlan, SnapshotIntervals
12
20
  from sqlmesh.core.plan import stages
13
21
  from sqlmesh.core.plan.evaluator import (
@@ -16,6 +24,8 @@ from sqlmesh.core.plan.evaluator import (
16
24
  from sqlmesh.core.state_sync import StateReader
17
25
  from sqlmesh.core.snapshot.definition import (
18
26
  SnapshotInfoMixin,
27
+ SnapshotIdAndVersion,
28
+ model_display_name,
19
29
  )
20
30
  from sqlmesh.utils import Verbosity, rich as srich, to_snake_case
21
31
  from sqlmesh.utils.date import to_ts
@@ -45,6 +55,15 @@ class PlanExplainer(PlanEvaluator):
45
55
  explainer_console = _get_explainer_console(
46
56
  self.console, plan.environment, self.default_catalog
47
57
  )
58
+
59
+ # add extra metadata that's only needed at this point for better --explain output
60
+ plan_stages = [
61
+ ExplainableRestatementStage.from_restatement_stage(stage, self.state_reader, plan)
62
+ if isinstance(stage, stages.RestatementStage)
63
+ else stage
64
+ for stage in plan_stages
65
+ ]
66
+
48
67
  explainer_console.explain(plan_stages)
49
68
 
50
69
 
@@ -54,6 +73,41 @@ class ExplainerConsole(abc.ABC):
54
73
  pass
55
74
 
56
75
 
76
+ @dataclass
77
+ class ExplainableRestatementStage(stages.RestatementStage):
78
+ """
79
+ This brings forward some calculations that would usually be done in the evaluator so the user can be given a better indication
80
+ of what might happen when they ask for the plan to be explained
81
+ """
82
+
83
+ snapshot_intervals_to_clear: t.Dict[str, t.List[SnapshotIntervalClearRequest]]
84
+ """Which snapshots from other environments would have intervals cleared as part of restatement, grouped by name."""
85
+
86
+ @classmethod
87
+ def from_restatement_stage(
88
+ cls: t.Type[ExplainableRestatementStage],
89
+ stage: stages.RestatementStage,
90
+ state_reader: StateReader,
91
+ plan: EvaluatablePlan,
92
+ ) -> ExplainableRestatementStage:
93
+ all_restatement_intervals = identify_restatement_intervals_across_snapshot_versions(
94
+ state_reader=state_reader,
95
+ prod_restatements=plan.restatements,
96
+ disable_restatement_models=plan.disabled_restatement_models,
97
+ loaded_snapshots={s.snapshot_id: s for s in stage.all_snapshots.values()},
98
+ )
99
+
100
+ # Group the interval clear requests by snapshot name to make them easier to write to the console
101
+ snapshot_intervals_to_clear = defaultdict(list)
102
+ for clear_request in all_restatement_intervals.values():
103
+ snapshot_intervals_to_clear[clear_request.snapshot.name].append(clear_request)
104
+
105
+ return cls(
106
+ snapshot_intervals_to_clear=snapshot_intervals_to_clear,
107
+ all_snapshots=stage.all_snapshots,
108
+ )
109
+
110
+
57
111
  MAX_TREE_LENGTH = 10
58
112
 
59
113
 
@@ -146,11 +200,37 @@ class RichExplainerConsole(ExplainerConsole):
146
200
  tree.add(display_name)
147
201
  return tree
148
202
 
149
- def visit_restatement_stage(self, stage: stages.RestatementStage) -> Tree:
150
- tree = Tree("[bold]Invalidate data intervals as part of restatement[/bold]")
151
- for snapshot_table_info, interval in stage.snapshot_intervals.items():
152
- display_name = self._display_name(snapshot_table_info)
153
- tree.add(f"{display_name} [{to_ts(interval[0])} - {to_ts(interval[1])}]")
203
+ def visit_explainable_restatement_stage(self, stage: ExplainableRestatementStage) -> Tree:
204
+ return self.visit_restatement_stage(stage)
205
+
206
+ def visit_restatement_stage(
207
+ self, stage: t.Union[ExplainableRestatementStage, stages.RestatementStage]
208
+ ) -> Tree:
209
+ tree = Tree(
210
+ "[bold]Invalidate data intervals in state for development environments to prevent old data from being promoted[/bold]\n"
211
+ "This only affects state and will not clear physical data from the tables until the next plan for each environment"
212
+ )
213
+
214
+ if isinstance(stage, ExplainableRestatementStage) and (
215
+ snapshot_intervals := stage.snapshot_intervals_to_clear
216
+ ):
217
+ for name, clear_requests in snapshot_intervals.items():
218
+ display_name = model_display_name(
219
+ name, self.environment_naming_info, self.default_catalog, self.dialect
220
+ )
221
+ interval_start = min(cr.interval[0] for cr in clear_requests)
222
+ interval_end = max(cr.interval[1] for cr in clear_requests)
223
+
224
+ if not interval_start or not interval_end:
225
+ continue
226
+
227
+ node = tree.add(f"{display_name} [{to_ts(interval_start)} - {to_ts(interval_end)}]")
228
+
229
+ all_environment_names = sorted(
230
+ set(env_name for cr in clear_requests for env_name in cr.environment_names)
231
+ )
232
+ node.add("in environments: " + ", ".join(all_environment_names))
233
+
154
234
  return tree
155
235
 
156
236
  def visit_backfill_stage(self, stage: stages.BackfillStage) -> Tree:
@@ -265,12 +345,14 @@ class RichExplainerConsole(ExplainerConsole):
265
345
 
266
346
  def _display_name(
267
347
  self,
268
- snapshot: SnapshotInfoMixin,
348
+ snapshot: t.Union[SnapshotInfoMixin, SnapshotIdAndVersion],
269
349
  environment_naming_info: t.Optional[EnvironmentNamingInfo] = None,
270
350
  ) -> str:
271
351
  return snapshot.display_name(
272
- environment_naming_info or self.environment_naming_info,
273
- self.default_catalog if self.verbosity < Verbosity.VERY_VERBOSE else None,
352
+ environment_naming_info=environment_naming_info or self.environment_naming_info,
353
+ default_catalog=self.default_catalog
354
+ if self.verbosity < Verbosity.VERY_VERBOSE
355
+ else None,
274
356
  dialect=self.dialect,
275
357
  )
276
358
 
@@ -12,8 +12,9 @@ from sqlmesh.core.snapshot.definition import (
12
12
  Snapshot,
13
13
  SnapshotTableInfo,
14
14
  SnapshotId,
15
- Interval,
15
+ snapshots_to_dag,
16
16
  )
17
+ from sqlmesh.utils.errors import PlanError
17
18
 
18
19
 
19
20
  @dataclass
@@ -98,14 +99,19 @@ class AuditOnlyRunStage:
98
99
 
99
100
  @dataclass
100
101
  class RestatementStage:
101
- """Restate intervals for given snapshots.
102
+ """Clear intervals from state for snapshots in *other* environments, when restatements are requested in prod.
103
+
104
+ This stage is effectively a "marker" stage to trigger the plan evaluator to perform the "clear intervals" logic after the BackfillStage has completed.
105
+ The "clear intervals" logic is executed just-in-time using the latest state available in order to pick up new snapshots that may have
106
+ been created while the BackfillStage was running, which is why we do not build a list of snapshots to clear at plan time and defer to evaluation time.
107
+
108
+ Note that this stage is only present on `prod` plans because dev plans do not need to worry about clearing intervals in other environments.
102
109
 
103
110
  Args:
104
- snapshot_intervals: Intervals to restate.
105
- all_snapshots: All snapshots in the plan by name.
111
+ all_snapshots: All snapshots in the plan by name. Note that this does not include the snapshots from other environments that will get their
112
+ intervals cleared, it's included here as an optimization to prevent having to re-fetch the current plan's snapshots
106
113
  """
107
114
 
108
- snapshot_intervals: t.Dict[SnapshotTableInfo, Interval]
109
115
  all_snapshots: t.Dict[str, Snapshot]
110
116
 
111
117
 
@@ -244,6 +250,7 @@ class PlanStagesBuilder:
244
250
  stored_snapshots = self.state_reader.get_snapshots(plan.environment.snapshots)
245
251
  snapshots = {**new_snapshots, **stored_snapshots}
246
252
  snapshots_by_name = {s.name: s for s in snapshots.values()}
253
+ dag = snapshots_to_dag(snapshots.values())
247
254
 
248
255
  all_selected_for_backfill_snapshots = {
249
256
  s.snapshot_id for s in snapshots.values() if plan.is_selected_for_backfill(s.name)
@@ -261,14 +268,21 @@ class PlanStagesBuilder:
261
268
  before_promote_snapshots = {
262
269
  s.snapshot_id
263
270
  for s in snapshots.values()
264
- if deployability_index.is_representative(s)
271
+ if (deployability_index.is_representative(s) or s.is_seed)
265
272
  and plan.is_selected_for_backfill(s.name)
266
273
  }
267
274
  after_promote_snapshots = all_selected_for_backfill_snapshots - before_promote_snapshots
268
275
  deployability_index = DeployabilityIndex.all_deployable()
269
276
 
277
+ snapshot_ids_with_schema_migration = [
278
+ s.snapshot_id for s in snapshots.values() if s.requires_schema_migration_in_prod
279
+ ]
280
+ # Include all upstream dependencies of snapshots that require schema migration to make sure
281
+ # the upstream tables are created before the schema updates are applied
270
282
  snapshots_with_schema_migration = [
271
- s for s in snapshots.values() if s.requires_schema_migration_in_prod
283
+ snapshots[s_id]
284
+ for s_id in dag.subdag(*snapshot_ids_with_schema_migration)
285
+ if snapshots[s_id].supports_schema_migration_in_prod
272
286
  ]
273
287
 
274
288
  snapshots_to_intervals = self._missing_intervals(
@@ -321,10 +335,6 @@ class PlanStagesBuilder:
321
335
  if audit_only_snapshots:
322
336
  stages.append(AuditOnlyRunStage(snapshots=list(audit_only_snapshots.values())))
323
337
 
324
- restatement_stage = self._get_restatement_stage(plan, snapshots_by_name)
325
- if restatement_stage:
326
- stages.append(restatement_stage)
327
-
328
338
  if missing_intervals_before_promote:
329
339
  stages.append(
330
340
  BackfillStage(
@@ -349,6 +359,15 @@ class PlanStagesBuilder:
349
359
  )
350
360
  )
351
361
 
362
+ # note: "restatement stage" (which is clearing intervals in state - not actually performing the restatements, that's the backfill stage)
363
+ # needs to come *after* the backfill stage so that at no time do other plans / runs see empty prod intervals and compete with this plan to try to fill them.
364
+ # in addition, when we update intervals in state, we only clear intervals from dev snapshots to force dev models to be backfilled based on the new prod data.
365
+ # we can leave prod intervals alone because by the time this plan finishes, the intervals in state have not actually changed, since restatement replaces
366
+ # data for existing intervals and does not produce new ones
367
+ restatement_stage = self._get_restatement_stage(plan, snapshots_by_name)
368
+ if restatement_stage:
369
+ stages.append(restatement_stage)
370
+
352
371
  stages.append(
353
372
  EnvironmentRecordUpdateStage(
354
373
  no_gaps_snapshot_names={s.name for s in before_promote_snapshots}
@@ -443,16 +462,18 @@ class PlanStagesBuilder:
443
462
  def _get_restatement_stage(
444
463
  self, plan: EvaluatablePlan, snapshots_by_name: t.Dict[str, Snapshot]
445
464
  ) -> t.Optional[RestatementStage]:
446
- snapshot_intervals_to_restate = {}
447
- for name, interval in plan.restatements.items():
448
- restated_snapshot = snapshots_by_name[name]
449
- restated_snapshot.remove_interval(interval)
450
- snapshot_intervals_to_restate[restated_snapshot.table_info] = interval
451
- if not snapshot_intervals_to_restate or plan.is_dev:
452
- return None
453
- return RestatementStage(
454
- snapshot_intervals=snapshot_intervals_to_restate, all_snapshots=snapshots_by_name
455
- )
465
+ if plan.restate_all_snapshots:
466
+ if plan.is_dev:
467
+ raise PlanError(
468
+ "Clearing intervals from state across dev model versions is only valid for prod plans"
469
+ )
470
+
471
+ if plan.restatements:
472
+ return RestatementStage(
473
+ all_snapshots=snapshots_by_name,
474
+ )
475
+
476
+ return None
456
477
 
457
478
  def _get_physical_layer_update_stage(
458
479
  self,