Skip to content

Commit 5259e5d

Browse files
[management] add domain and service cleanup migration (#5850)
1 parent ebd78e0 commit 5259e5d

3 files changed

Lines changed: 296 additions & 0 deletions

File tree

management/server/migration/migration.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,102 @@ func MigrateJsonToTable[T any](ctx context.Context, db *gorm.DB, columnName stri
489489
return nil
490490
}
491491

492+
// hasForeignKey checks whether a foreign key constraint exists on the given table and column.
493+
func hasForeignKey(db *gorm.DB, table, column string) bool {
494+
var count int64
495+
496+
switch db.Name() {
497+
case "postgres":
498+
db.Raw(`
499+
SELECT COUNT(*) FROM information_schema.key_column_usage kcu
500+
JOIN information_schema.table_constraints tc
501+
ON tc.constraint_name = kcu.constraint_name
502+
AND tc.table_schema = kcu.table_schema
503+
WHERE tc.constraint_type = 'FOREIGN KEY'
504+
AND kcu.table_name = ?
505+
AND kcu.column_name = ?
506+
`, table, column).Scan(&count)
507+
case "mysql":
508+
db.Raw(`
509+
SELECT COUNT(*) FROM information_schema.key_column_usage
510+
WHERE table_schema = DATABASE()
511+
AND table_name = ?
512+
AND column_name = ?
513+
AND referenced_table_name IS NOT NULL
514+
`, table, column).Scan(&count)
515+
default: // sqlite
516+
type fkInfo struct {
517+
From string
518+
}
519+
var fks []fkInfo
520+
db.Raw(fmt.Sprintf("PRAGMA foreign_key_list(%s)", table)).Scan(&fks)
521+
for _, fk := range fks {
522+
if fk.From == column {
523+
return true
524+
}
525+
}
526+
return false
527+
}
528+
529+
return count > 0
530+
}
531+
532+
// CleanupOrphanedResources deletes rows from the table of model T where the foreign
533+
// key column (fkColumn) references a row in the table of model R that no longer exists.
534+
func CleanupOrphanedResources[T any, R any](ctx context.Context, db *gorm.DB, fkColumn string) error {
535+
var model T
536+
var refModel R
537+
538+
if !db.Migrator().HasTable(&model) {
539+
log.WithContext(ctx).Debugf("table for %T does not exist, no cleanup needed", model)
540+
return nil
541+
}
542+
543+
if !db.Migrator().HasTable(&refModel) {
544+
log.WithContext(ctx).Debugf("referenced table for %T does not exist, no cleanup needed", refModel)
545+
return nil
546+
}
547+
548+
stmtT := &gorm.Statement{DB: db}
549+
if err := stmtT.Parse(&model); err != nil {
550+
return fmt.Errorf("parse model %T: %w", model, err)
551+
}
552+
childTable := stmtT.Schema.Table
553+
554+
stmtR := &gorm.Statement{DB: db}
555+
if err := stmtR.Parse(&refModel); err != nil {
556+
return fmt.Errorf("parse reference model %T: %w", refModel, err)
557+
}
558+
parentTable := stmtR.Schema.Table
559+
560+
if !db.Migrator().HasColumn(&model, fkColumn) {
561+
log.WithContext(ctx).Debugf("column %s does not exist in table %s, no cleanup needed", fkColumn, childTable)
562+
return nil
563+
}
564+
565+
// If a foreign key constraint already exists on the column, the DB itself
566+
// enforces referential integrity and orphaned rows cannot exist.
567+
if hasForeignKey(db, childTable, fkColumn) {
568+
log.WithContext(ctx).Debugf("foreign key constraint for %s already exists on %s, no cleanup needed", fkColumn, childTable)
569+
return nil
570+
}
571+
572+
result := db.Exec(
573+
fmt.Sprintf(
574+
"DELETE FROM %s WHERE %s NOT IN (SELECT id FROM %s)",
575+
childTable, fkColumn, parentTable,
576+
),
577+
)
578+
if result.Error != nil {
579+
return fmt.Errorf("cleanup orphaned rows in %s: %w", childTable, result.Error)
580+
}
581+
582+
log.WithContext(ctx).Infof("Cleaned up %d orphaned rows from %s where %s had no matching row in %s",
583+
result.RowsAffected, childTable, fkColumn, parentTable)
584+
585+
return nil
586+
}
587+
492588
func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error {
493589
if !db.Migrator().HasTable("peers") {
494590
log.WithContext(ctx).Debug("peers table does not exist, skipping duplicate key cleanup")

management/server/migration/migration_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,3 +441,197 @@ func TestRemoveDuplicatePeerKeys_NoTable(t *testing.T) {
441441
err := migration.RemoveDuplicatePeerKeys(context.Background(), db)
442442
require.NoError(t, err, "Should not fail when table does not exist")
443443
}
444+
445+
type testParent struct {
446+
ID string `gorm:"primaryKey"`
447+
}
448+
449+
func (testParent) TableName() string {
450+
return "test_parents"
451+
}
452+
453+
type testChild struct {
454+
ID string `gorm:"primaryKey"`
455+
ParentID string
456+
}
457+
458+
func (testChild) TableName() string {
459+
return "test_children"
460+
}
461+
462+
type testChildWithFK struct {
463+
ID string `gorm:"primaryKey"`
464+
ParentID string `gorm:"index"`
465+
Parent *testParent `gorm:"foreignKey:ParentID"`
466+
}
467+
468+
func (testChildWithFK) TableName() string {
469+
return "test_children"
470+
}
471+
472+
func setupOrphanTestDB(t *testing.T, models ...any) *gorm.DB {
473+
t.Helper()
474+
db := setupDatabase(t)
475+
for _, m := range models {
476+
_ = db.Migrator().DropTable(m)
477+
}
478+
err := db.AutoMigrate(models...)
479+
require.NoError(t, err, "Failed to auto-migrate tables")
480+
return db
481+
}
482+
483+
func TestCleanupOrphanedResources_NoChildTable(t *testing.T) {
484+
db := setupDatabase(t)
485+
_ = db.Migrator().DropTable(&testChild{})
486+
_ = db.Migrator().DropTable(&testParent{})
487+
488+
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
489+
require.NoError(t, err, "Should not fail when child table does not exist")
490+
}
491+
492+
func TestCleanupOrphanedResources_NoParentTable(t *testing.T) {
493+
db := setupDatabase(t)
494+
_ = db.Migrator().DropTable(&testParent{})
495+
_ = db.Migrator().DropTable(&testChild{})
496+
497+
err := db.AutoMigrate(&testChild{})
498+
require.NoError(t, err)
499+
500+
err = migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
501+
require.NoError(t, err, "Should not fail when parent table does not exist")
502+
}
503+
504+
func TestCleanupOrphanedResources_EmptyTables(t *testing.T) {
505+
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
506+
507+
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
508+
require.NoError(t, err, "Should not fail on empty tables")
509+
510+
var count int64
511+
db.Model(&testChild{}).Count(&count)
512+
assert.Equal(t, int64(0), count)
513+
}
514+
515+
func TestCleanupOrphanedResources_NoOrphans(t *testing.T) {
516+
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
517+
518+
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
519+
require.NoError(t, db.Create(&testParent{ID: "p2"}).Error)
520+
require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error)
521+
require.NoError(t, db.Create(&testChild{ID: "c2", ParentID: "p2"}).Error)
522+
523+
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
524+
require.NoError(t, err)
525+
526+
var count int64
527+
db.Model(&testChild{}).Count(&count)
528+
assert.Equal(t, int64(2), count, "All children should remain when no orphans")
529+
}
530+
531+
func TestCleanupOrphanedResources_AllOrphans(t *testing.T) {
532+
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
533+
534+
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c1", "gone1").Error)
535+
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c2", "gone2").Error)
536+
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c3", "gone3").Error)
537+
538+
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
539+
require.NoError(t, err)
540+
541+
var count int64
542+
db.Model(&testChild{}).Count(&count)
543+
assert.Equal(t, int64(0), count, "All orphaned children should be deleted")
544+
}
545+
546+
func TestCleanupOrphanedResources_MixedValidAndOrphaned(t *testing.T) {
547+
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
548+
549+
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
550+
require.NoError(t, db.Create(&testParent{ID: "p2"}).Error)
551+
552+
require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error)
553+
require.NoError(t, db.Create(&testChild{ID: "c2", ParentID: "p2"}).Error)
554+
require.NoError(t, db.Create(&testChild{ID: "c3", ParentID: "p1"}).Error)
555+
556+
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c4", "gone1").Error)
557+
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c5", "gone2").Error)
558+
559+
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
560+
require.NoError(t, err)
561+
562+
var remaining []testChild
563+
require.NoError(t, db.Order("id").Find(&remaining).Error)
564+
565+
assert.Len(t, remaining, 3, "Only valid children should remain")
566+
assert.Equal(t, "c1", remaining[0].ID)
567+
assert.Equal(t, "c2", remaining[1].ID)
568+
assert.Equal(t, "c3", remaining[2].ID)
569+
}
570+
571+
func TestCleanupOrphanedResources_Idempotent(t *testing.T) {
572+
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
573+
574+
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
575+
require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error)
576+
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c2", "gone").Error)
577+
578+
ctx := context.Background()
579+
580+
err := migration.CleanupOrphanedResources[testChild, testParent](ctx, db, "parent_id")
581+
require.NoError(t, err)
582+
583+
var count int64
584+
db.Model(&testChild{}).Count(&count)
585+
assert.Equal(t, int64(1), count)
586+
587+
err = migration.CleanupOrphanedResources[testChild, testParent](ctx, db, "parent_id")
588+
require.NoError(t, err)
589+
590+
db.Model(&testChild{}).Count(&count)
591+
assert.Equal(t, int64(1), count, "Count should remain the same after second run")
592+
}
593+
594+
func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
595+
engine := os.Getenv("NETBIRD_STORE_ENGINE")
596+
if engine != "postgres" && engine != "mysql" {
597+
t.Skip("FK constraint early-exit test requires postgres or mysql")
598+
}
599+
600+
db := setupDatabase(t)
601+
_ = db.Migrator().DropTable(&testChildWithFK{})
602+
_ = db.Migrator().DropTable(&testParent{})
603+
604+
err := db.AutoMigrate(&testParent{}, &testChildWithFK{})
605+
require.NoError(t, err)
606+
607+
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
608+
require.NoError(t, db.Create(&testParent{ID: "p2"}).Error)
609+
require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error)
610+
require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error)
611+
612+
switch engine {
613+
case "postgres":
614+
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error)
615+
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
616+
require.NoError(t, db.Exec(
617+
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
618+
"FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID",
619+
).Error)
620+
case "mysql":
621+
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error)
622+
require.NoError(t, db.Exec("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent").Error)
623+
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
624+
require.NoError(t, db.Exec(
625+
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
626+
"FOREIGN KEY (parent_id) REFERENCES test_parents(id)",
627+
).Error)
628+
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error)
629+
}
630+
631+
err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id")
632+
require.NoError(t, err)
633+
634+
var count int64
635+
db.Model(&testChildWithFK{}).Count(&count)
636+
assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists")
637+
}

management/server/store/store.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,12 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
448448
func(db *gorm.DB) error {
449449
return migration.RemoveDuplicatePeerKeys(ctx, db)
450450
},
451+
func(db *gorm.DB) error {
452+
return migration.CleanupOrphanedResources[rpservice.Service, types.Account](ctx, db, "account_id")
453+
},
454+
func(db *gorm.DB) error {
455+
return migration.CleanupOrphanedResources[domain.Domain, types.Account](ctx, db, "account_id")
456+
},
451457
}
452458
}
453459

0 commit comments

Comments
 (0)