@@ -1489,3 +1489,115 @@ def hold_read_lock():
14891489 self .assertEqual (counts ["created" ], 1 )
14901490 self .assertEqual (counts ["missing" ], [])
14911491 self .assertTrue (any ("'outcome': 'created'" in line for line in logs .output ))
1492+
1493+
1494+ class TestReconcileStalePartitionTracking (IamTestCase ):
1495+ """Safe cleanup of partitioned_tables rows whose physical tables are gone (COST-8415)."""
1496+
1497+ SCHEMA_NAME = "org1234567"
1498+ LIVE_TABLE = "__pg_partition_reconcile_live"
1499+ STALE_TABLE = "__pg_partition_reconcile_stale"
1500+
1501+ def setUp (self ):
1502+ super ().setUp ()
1503+ self ._create_partitioned_table (self .LIVE_TABLE )
1504+ self ._create_partitioned_table (self .STALE_TABLE )
1505+ _execute (f"drop table { self .SCHEMA_NAME } .{ self .STALE_TABLE } cascade" )
1506+
1507+ def tearDown (self ):
1508+ with schema_context (self .SCHEMA_NAME ):
1509+ PartitionedTable .objects .filter (
1510+ schema_name = self .SCHEMA_NAME ,
1511+ partition_of_table_name__in = [self .LIVE_TABLE , self .STALE_TABLE ],
1512+ ).delete ()
1513+ _execute (f"drop table if exists { self .SCHEMA_NAME } .{ self .LIVE_TABLE } cascade" )
1514+ _execute (f"drop table if exists { self .SCHEMA_NAME } .{ self .STALE_TABLE } cascade" )
1515+ # Name-reuse helper tables from individual tests
1516+ _execute (f"drop table if exists { self .SCHEMA_NAME } .{ self .STALE_TABLE } _default cascade" )
1517+ super ().tearDown ()
1518+
1519+ def _create_partitioned_table (self , table_name ):
1520+ _execute (
1521+ f"""
1522+ create table if not exists { self .SCHEMA_NAME } .{ table_name } (
1523+ id bigserial,
1524+ usage_start date not null,
1525+ primary key (usage_start, id)
1526+ )
1527+ partition by range (usage_start);
1528+ """
1529+ )
1530+ with schema_context (self .SCHEMA_NAME ):
1531+ ppart .get_or_create_partition (
1532+ {
1533+ "schema_name" : self .SCHEMA_NAME ,
1534+ "table_name" : f"{ table_name } _default" ,
1535+ "partition_of_table_name" : table_name ,
1536+ "partition_type" : PartitionedTable .RANGE ,
1537+ "partition_col" : "usage_start" ,
1538+ "partition_parameters" : {"default" : True },
1539+ "active" : True ,
1540+ }
1541+ )
1542+
1543+ def _tracking (self , table_name ):
1544+ with schema_context (self .SCHEMA_NAME ):
1545+ return PartitionedTable .objects .filter (schema_name = self .SCHEMA_NAME , table_name = table_name ).first ()
1546+
1547+ def test_dry_run_classifies_safe_orphan_and_does_not_delete (self ):
1548+ result = ppart .reconcile_stale_partition_tracking (self .SCHEMA_NAME , delete = False )
1549+ self .assertFalse (result ["delete" ])
1550+ self .assertEqual (result ["deleted" ], 0 )
1551+ self .assertGreaterEqual (result ["counts" ][ppart .TRACKING_SAFE_ORPHAN ], 1 )
1552+ self .assertIsNotNone (self ._tracking (f"{ self .STALE_TABLE } _default" ))
1553+ self .assertIsNotNone (self ._tracking (f"{ self .LIVE_TABLE } _default" ))
1554+
1555+ def test_delete_removes_only_safe_orphan_rows (self ):
1556+ result = ppart .reconcile_stale_partition_tracking (self .SCHEMA_NAME , delete = True )
1557+ self .assertGreaterEqual (result ["deleted" ], 1 )
1558+ self .assertIsNone (self ._tracking (f"{ self .STALE_TABLE } _default" ))
1559+ self .assertIsNotNone (self ._tracking (f"{ self .LIVE_TABLE } _default" ))
1560+ self .assertTrue (ppart .relation_exists (self .SCHEMA_NAME , f"{ self .LIVE_TABLE } _default" ))
1561+
1562+ def test_name_reuse_is_physical_present_and_not_deleted (self ):
1563+ """A same-named default attached to another parent must not be deleted."""
1564+ replacement = "__pg_partition_reconcile_replacement"
1565+ _execute (
1566+ f"""
1567+ create table { self .SCHEMA_NAME } .{ replacement } (
1568+ id bigserial,
1569+ usage_start date not null,
1570+ primary key (usage_start, id)
1571+ )
1572+ partition by range (usage_start);
1573+ """
1574+ )
1575+ _execute (
1576+ f"create table { self .SCHEMA_NAME } .{ self .STALE_TABLE } _default "
1577+ f"partition of { self .SCHEMA_NAME } .{ replacement } default"
1578+ )
1579+ try :
1580+ classified = ppart .classify_partition_tracking (self .SCHEMA_NAME )
1581+ stale_default = next (row for row in classified if row ["table_name" ] == f"{ self .STALE_TABLE } _default" )
1582+ self .assertEqual (stale_default ["classification" ], ppart .TRACKING_PHYSICAL_PRESENT )
1583+ self .assertEqual (stale_default ["partition_of_table_name" ], self .STALE_TABLE )
1584+
1585+ result = ppart .reconcile_stale_partition_tracking (self .SCHEMA_NAME , delete = True )
1586+ self .assertIsNotNone (self ._tracking (f"{ self .STALE_TABLE } _default" ))
1587+ self .assertTrue (ppart .relation_exists (self .SCHEMA_NAME , f"{ self .STALE_TABLE } _default" ))
1588+ # Live default still present; name-reused stale default was not among deleted safe orphans
1589+ self .assertNotIn (
1590+ f"{ self .STALE_TABLE } _default" ,
1591+ [s ["table_name" ] for s in result ["samples" ].get (ppart .TRACKING_SAFE_ORPHAN , [])],
1592+ )
1593+ finally :
1594+ _execute (f"drop table if exists { self .SCHEMA_NAME } .{ replacement } cascade" )
1595+
1596+ def test_delete_sql_skips_row_when_relation_exists (self ):
1597+ """DELETE ... NOT EXISTS must not remove tracking for a present relation."""
1598+ live_default = self ._tracking (f"{ self .LIVE_TABLE } _default" )
1599+ self .assertIsNotNone (live_default )
1600+ deleted = ppart ._delete_safe_orphan_tracking (self .SCHEMA_NAME , [live_default .id ])
1601+ self .assertEqual (deleted , 0 )
1602+ self .assertIsNotNone (self ._tracking (f"{ self .LIVE_TABLE } _default" ))
1603+ self .assertTrue (ppart .relation_exists (self .SCHEMA_NAME , f"{ self .LIVE_TABLE } _default" ))
0 commit comments