Skip to content

Commit f708d82

Browse files
committed
Fix independent ownership of snapshot tables during cleanup
Signed-off-by: Iurie Virlan <38211890+IuraHD@users.noreply.github.com>
1 parent a6ba67d commit f708d82

6 files changed

Lines changed: 491 additions & 54 deletions

File tree

‎sqlmesh/core/snapshot/definition.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,8 +760,10 @@ def hydrate_with_intervals_by_version(
760760
List of target snapshots with hydrated intervals.
761761
"""
762762
intervals_by_name_version = defaultdict(list)
763+
intervals_by_dev_version = defaultdict(list)
763764
for interval in intervals:
764765
intervals_by_name_version[(interval.name, interval.version)].append(interval)
766+
intervals_by_dev_version[(interval.name, interval.dev_version)].append(interval)
765767

766768
result = []
767769
for snapshot in snapshots:
@@ -771,6 +773,17 @@ def hydrate_with_intervals_by_version(
771773
for interval in snapshot_intervals:
772774
snapshot.merge_intervals(interval)
773775

776+
for interval in intervals_by_dev_version.get((snapshot.name, snapshot.dev_version), []):
777+
if interval.version != snapshot.version_get_or_generate():
778+
snapshot.merge_intervals(
779+
interval.copy(
780+
update={
781+
"intervals": [],
782+
"last_altered_ts": None,
783+
"pending_restatement_intervals": [],
784+
}
785+
)
786+
)
774787
result.append(snapshot)
775788

776789
return result
@@ -1555,6 +1568,28 @@ def __getstate__(self) -> t.Dict[t.Any, t.Any]:
15551568
class SnapshotTableCleanupTask(PydanticModel):
15561569
snapshot: SnapshotTableInfo
15571570
dev_table_only: bool
1571+
# Default preserves the behavior of tasks serialized before independent cleanup.
1572+
delete_dev_table: bool = True
1573+
# Physical objects and logical interval versions have different ownership.
1574+
# None retains the meaning of cleanup tasks persisted before these fields.
1575+
delete_prod_intervals: t.Optional[bool] = None
1576+
delete_dev_intervals: t.Optional[bool] = None
1577+
1578+
@property
1579+
def clears_prod_intervals(self) -> bool:
1580+
return (
1581+
not self.dev_table_only
1582+
if self.delete_prod_intervals is None
1583+
else self.delete_prod_intervals
1584+
)
1585+
1586+
@property
1587+
def clears_dev_intervals(self) -> bool:
1588+
return (
1589+
self.delete_dev_table
1590+
if self.delete_dev_intervals is None
1591+
else self.delete_dev_intervals
1592+
)
15581593

15591594

15601595
SnapshotIdLike = t.Union[SnapshotId, SnapshotIdAndVersion, SnapshotTableInfo, Snapshot]

‎sqlmesh/core/snapshot/evaluator.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,17 +543,16 @@ def cleanup(
543543
len(skipped),
544544
", ".join(f"{sid} (gateway={gw})" for sid, gw in skipped),
545545
)
546-
snapshots_to_dev_table_only = {
547-
t.snapshot.snapshot_id: t.dev_table_only for t in filtered_targets
548-
}
546+
cleanup_tasks_by_snapshot = {t.snapshot.snapshot_id: t for t in filtered_targets}
549547
with self.concurrent_context():
550548
errors, _ = concurrent_apply_to_snapshots(
551549
[t.snapshot for t in filtered_targets],
552550
lambda s: self._cleanup_snapshot(
553551
s,
554-
snapshots_to_dev_table_only[s.snapshot_id],
552+
cleanup_tasks_by_snapshot[s.snapshot_id].dev_table_only,
555553
self.get_adapter(s.model_gateway),
556554
on_complete,
555+
delete_dev_table=cleanup_tasks_by_snapshot[s.snapshot_id].delete_dev_table,
557556
),
558557
self.ddl_concurrent_tasks,
559558
reverse_order=True,
@@ -1347,10 +1346,13 @@ def _cleanup_snapshot(
13471346
dev_table_only: bool,
13481347
adapter: EngineAdapter,
13491348
on_complete: t.Optional[t.Callable[[str], None]],
1349+
delete_dev_table: bool = True,
13501350
) -> None:
13511351
snapshot = snapshot.table_info
13521352

1353-
table_names = [(False, snapshot.table_name(is_deployable=False))]
1353+
table_names = (
1354+
[(False, snapshot.table_name(is_deployable=False))] if delete_dev_table else []
1355+
)
13541356
if not dev_table_only:
13551357
table_names.append((True, snapshot.table_name(is_deployable=True)))
13561358

‎sqlmesh/core/state_sync/db/interval.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -277,16 +277,35 @@ def _get_snapshot_intervals(
277277
t.Tuple[str, str, t.Optional[str], t.Optional[str]], SnapshotIntervals
278278
] = {}
279279

280-
for where in (
281-
snapshot_name_version_filter(
282-
self.engine_adapter,
283-
snapshots,
284-
alias="intervals",
285-
batch_size=self.SNAPSHOT_BATCH_SIZE,
280+
filters = (
281+
list(
282+
snapshot_name_version_filter(
283+
self.engine_adapter,
284+
snapshots,
285+
alias="intervals",
286+
batch_size=self.SNAPSHOT_BATCH_SIZE,
287+
)
286288
)
287289
if snapshots
288290
else [None]
289-
):
291+
)
292+
if snapshots:
293+
dev_keys = [
294+
SnapshotNameVersion(name=s.name, version=s.dev_version)
295+
for s in snapshots
296+
if hasattr(s, "dev_version")
297+
]
298+
filters.extend(
299+
where.and_(exp.column("is_dev", table="intervals"))
300+
for where in snapshot_name_version_filter(
301+
self.engine_adapter,
302+
dev_keys,
303+
alias="intervals",
304+
version_column_name="dev_version",
305+
batch_size=self.SNAPSHOT_BATCH_SIZE,
306+
)
307+
)
308+
for where in filters:
290309
rows = fetchall(self.engine_adapter, query.where(where))
291310
for (
292311
interval_id,
@@ -301,6 +320,8 @@ def _get_snapshot_intervals(
301320
is_pending_restatement,
302321
last_altered_ts,
303322
) in rows:
323+
if interval_id in interval_ids:
324+
continue
304325
interval_ids.add(interval_id)
305326
merge_key = (name, version, dev_version, identifier)
306327
# Pending restatement intervals are merged by name and version
@@ -420,7 +441,7 @@ def _delete_intervals_by_dev_version(self, targets: t.List[SnapshotTableCleanupT
420441
dev_keys_to_delete = [
421442
SnapshotNameVersion(name=t.snapshot.name, version=t.snapshot.dev_version)
422443
for t in targets
423-
if t.dev_table_only
444+
if t.clears_dev_intervals
424445
]
425446
if not dev_keys_to_delete:
426447
return
@@ -436,7 +457,7 @@ def _delete_intervals_by_dev_version(self, targets: t.List[SnapshotTableCleanupT
436457

437458
def _delete_intervals_by_version(self, targets: t.List[SnapshotTableCleanupTask]) -> None:
438459
"""Deletes intervals for snapshot versions that are no longer used."""
439-
non_dev_keys_to_delete = [t.snapshot for t in targets if not t.dev_table_only]
460+
non_dev_keys_to_delete = [t.snapshot for t in targets if t.clears_prod_intervals]
440461
if not non_dev_keys_to_delete:
441462
return
442463

@@ -446,7 +467,9 @@ def _delete_intervals_by_version(self, targets: t.List[SnapshotTableCleanupTask]
446467
alias=None,
447468
batch_size=self.SNAPSHOT_BATCH_SIZE,
448469
):
449-
self.engine_adapter.delete_from(self.intervals_table, where)
470+
self.engine_adapter.delete_from(
471+
self.intervals_table, where.and_(exp.column("is_dev").not_())
472+
)
450473

451474

452475
def _intervals_to_df(

‎sqlmesh/core/state_sync/db/snapshot.py‎

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
)
3838
from sqlmesh.utils.migration import index_text_type, blob_text_type
3939
from sqlmesh.utils.date import now_timestamp, TimeLike, to_timestamp
40-
from sqlmesh.utils import unique
4140

4241
if t.TYPE_CHECKING:
4342
import pandas as pd
@@ -225,12 +224,6 @@ def get_expired_snapshots(
225224
if not expired_candidates:
226225
return None
227226

228-
def _is_snapshot_used(snapshot: SnapshotIdAndVersion) -> bool:
229-
return (
230-
snapshot.snapshot_id in promoted_snapshot_ids
231-
or snapshot.snapshot_id not in expired_candidates
232-
)
233-
234227
# Extract cursor values from last row for pagination
235228
last_row = rows[-1]
236229
last_row_boundary = RowBoundary(
@@ -244,51 +237,66 @@ def _is_snapshot_used(snapshot: SnapshotIdAndVersion) -> bool:
244237
end=last_row_boundary,
245238
)
246239

247-
unique_expired_versions = unique(expired_candidates.values())
248240
expired_snapshot_ids: t.Set[SnapshotId] = set()
249241
cleanup_tasks: t.List[SnapshotTableCleanupTask] = []
250-
251-
snapshots = self._get_snapshots_with_same_version(unique_expired_versions)
252-
242+
# A version identifies interval state, not a physical object. The same
243+
# version may exist in different schemas or gateways. Load the table
244+
# metadata for peers as well as candidates before determining ownership.
245+
peers = self.get_snapshots_by_names(
246+
{s.name for s in expired_candidates}, exclude_expired=False
247+
)
248+
full_snapshots = self._get_snapshots([s.snapshot_id for s in peers])
249+
snapshots = list(full_snapshots.values())
253250
snapshots_by_version = defaultdict(set)
254251
snapshots_by_dev_version = defaultdict(set)
252+
table_owners = defaultdict(set)
253+
254+
def table_key(snapshot: Snapshot, deployable: bool) -> t.Tuple[t.Optional[str], str]:
255+
return (
256+
snapshot.model_gateway,
257+
snapshot.table_name(is_deployable=deployable),
258+
)
259+
255260
for s in snapshots:
256261
snapshots_by_version[(s.name, s.version)].add(s.snapshot_id)
257262
snapshots_by_dev_version[(s.name, s.dev_version)].add(s.snapshot_id)
263+
if s.is_model and not s.is_symbolic:
264+
for deployable in (True, False):
265+
table_owners[table_key(s, deployable)].add(s.snapshot_id)
258266

259-
expired_snapshots = [s for s in snapshots if not _is_snapshot_used(s)]
260-
all_expired_snapshot_ids = {s.snapshot_id for s in expired_snapshots}
261-
262-
cleanup_targets: t.List[t.Tuple[SnapshotId, bool]] = []
267+
expired_snapshots = [
268+
full_snapshots[sid] for sid in expired_candidates if sid in full_snapshots
269+
]
263270
for snapshot in expired_snapshots:
264-
shared_version_snapshots = snapshots_by_version[(snapshot.name, snapshot.version)]
265-
shared_version_snapshots.discard(snapshot.snapshot_id)
266-
267-
shared_dev_version_snapshots = snapshots_by_dev_version[
268-
(snapshot.name, snapshot.dev_version)
269-
]
270-
shared_dev_version_snapshots.discard(snapshot.snapshot_id)
271-
272-
if not shared_dev_version_snapshots:
273-
dev_table_only = bool(shared_version_snapshots)
274-
cleanup_targets.append((snapshot.snapshot_id, dev_table_only))
275-
276-
snapshot_ids_to_cleanup = [snapshot_id for snapshot_id, _ in cleanup_targets]
277-
full_snapshots = self._get_snapshots(snapshot_ids_to_cleanup)
278-
for snapshot_id, dev_table_only in cleanup_targets:
279-
if snapshot_id in full_snapshots:
271+
prod_owners = snapshots_by_version[(snapshot.name, snapshot.version)]
272+
dev_owners = snapshots_by_dev_version[(snapshot.name, snapshot.dev_version)]
273+
prod_owners.discard(snapshot.snapshot_id)
274+
dev_owners.discard(snapshot.snapshot_id)
275+
delete_prod = delete_dev = False
276+
if snapshot.is_model and not snapshot.is_symbolic:
277+
prod_table_owners = table_owners[table_key(snapshot, True)]
278+
dev_table_owners = table_owners[table_key(snapshot, False)]
279+
prod_table_owners.discard(snapshot.snapshot_id)
280+
dev_table_owners.discard(snapshot.snapshot_id)
281+
delete_prod = not prod_table_owners
282+
delete_dev = not dev_table_owners
283+
# An interval-only task is required when the last logical version
284+
# expires but a physical object is retained by a different version.
285+
if delete_prod or delete_dev or not prod_owners or not dev_owners:
280286
cleanup_tasks.append(
281287
SnapshotTableCleanupTask(
282-
snapshot=full_snapshots[snapshot_id].table_info,
283-
dev_table_only=dev_table_only,
288+
snapshot=snapshot.table_info,
289+
dev_table_only=not delete_prod,
290+
delete_dev_table=delete_dev,
291+
delete_prod_intervals=(
292+
None if (not prod_owners) == delete_prod else not prod_owners
293+
),
294+
delete_dev_intervals=(
295+
None if (not dev_owners) == delete_dev else not dev_owners
296+
),
284297
)
285298
)
286-
expired_snapshot_ids.add(snapshot_id)
287-
all_expired_snapshot_ids.discard(snapshot_id)
288-
289-
# Add any remaining expired snapshots that don't require cleanup
290-
if all_expired_snapshot_ids:
291-
expired_snapshot_ids.update(all_expired_snapshot_ids)
299+
expired_snapshot_ids.add(snapshot.snapshot_id)
292300

293301
if expired_snapshot_ids or cleanup_tasks:
294302
return ExpiredSnapshotBatch(

0 commit comments

Comments
 (0)