From fa8fb463b3710671b1fb8478101a9a037f4b6df8 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:28:50 +0200 Subject: [PATCH 1/2] Harden guild purge ordering and schema drift Refactors guild purge into explicit indirect, ordered, and legacy table groups to prevent FK violations and cover previously missed guild-scoped data (including panel children, KB, labels, and experiment/gallery records). Adds a shared purge helper with per-table logging and relation existence checks so missing legacy tables no longer abort the whole purge transaction. Also updates module metadata and bumps `github.com/TicketsBot-cloud/common` (with local replace) plus `golang.org/x/sys`. --- go.mod | 8 +- go.sum | 5 ++ guildpurge.go | 235 ++++++++++++++++++++++++++++++++------------------ 3 files changed, 161 insertions(+), 87 deletions(-) diff --git a/go.mod b/go.mod index fc852c0..8bdfeb5 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module github.com/TicketsBot-cloud/database -go 1.22 +go 1.22.0 toolchain go1.22.4 +replace github.com/TicketsBot-cloud/common => ../common + require ( - github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98 + github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e github.com/google/uuid v1.6.0 github.com/jackc/pgtype v1.14.0 github.com/jackc/pgx v3.6.2+incompatible @@ -28,6 +30,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.28.0 // indirect - golang.org/x/sys v0.26.0 // indirect + golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index af24e37..581068e 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98 h1:HwtXrqSv6y5E8mTnkOtISTaQr6dsK/lfUC8tUA6SzSU= github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98/go.mod h1:iiZhl7w5DeTEGzAeq/hzani8+ILQz60g4JFkOy2vkuM= +github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e h1:66mDH3lvotbWSgQXNVbssCvUgy6Yg1mqKl7RmEdr6y4= +github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -121,6 +123,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -174,6 +177,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/guildpurge.go b/guildpurge.go index 319038f..6f86424 100644 --- a/guildpurge.go +++ b/guildpurge.go @@ -4,14 +4,113 @@ import ( "context" "fmt" + "github.com/jackc/pgx/v4" "go.uber.org/zap" ) +// Runs before guildPurgeTables so the panel children go while panels still exists. +var guildPurgeIndirect = []struct { + table string + query string +}{ + // No foreign key to panels, so deleting panels would orphan these. + {"panel_ticket_permissions", `DELETE FROM panel_ticket_permissions WHERE panel_id IN (SELECT panel_id FROM panels WHERE guild_id = $1)`}, + {"panel_kb_categories", `DELETE FROM panel_kb_categories WHERE panel_id IN (SELECT panel_id FROM panels WHERE guild_id = $1)`}, + + {"gallery_listings", `DELETE FROM gallery_listings WHERE source_guild_id = $1`}, + {"experiment_exposures", `DELETE FROM experiment_exposures WHERE identifier_type = 'guild_id' AND identifier = ($1::int8)::text`}, +} + +// Order is load-bearing: most children of tickets have no ON DELETE action, so +// listing one after its parent raises SQLSTATE 23503. +var guildPurgeTables = []string{ + "archive_dm_messages", + "archive_messages", + "auto_close_exclude", + "category_update_queue", + "close_reason", + "close_request", + "exit_survey_responses", + "first_response_time", + "participant", + "service_ratings", + "ticket_claims", + "ticket_label_assignments", + "ticket_last_message", + "ticket_members", + "ticket_message_counts", + "webhooks", + + "tickets", + "guild_ticket_counters", + "ticket_labels", + + // Panels reference embeds and forms. + "panels", + "multi_panels", + + "support_team", + "forms", + "embeds", + + "custom_integration_secret_values", + "custom_integration_guilds", + + "kb_article_feedback", + "kb_articles", + "kb_categories", + "kb_settings", + + "active_language", + "audit_logs", + "blacklist", + "channel_category", + "claim_settings", + "custom_colours", + "dashboard_onboarding", + "guild_metadata", + "legacy_premium_entitlement_guilds", + "on_call", + "permissions", + "premium_guilds", + "role_blacklist", + "role_permissions", + "settings", + "staff_override", + "tags", + "used_keys", + "user_guilds", + "whitelabel_allowed_guilds", + "whitelabel_guilds", +} + +// Dropped from the schema, but dropping a Go wrapper does not drop the table, so +// deployments may still hold rows. Remove an entry once production is confirmed clean. +var guildPurgeLegacy = []string{ + "archive_channel", + "auto_close", + "close_confirmation", + "feedback_enabled", + "import_logs", + "import_mapping", + "naming_scheme", + "ticket_limit", + "ticket_permissions", + "users_can_close", + "welcome_messages", +} + +// Guild-scoped but deliberately retained; keeps the drift test honest. +var guildPurgeExempt = map[string]string{ + "guild_leave_time": "drives the purge itself; the caller deletes the row once the purge succeeds", + "entitlements": "billing record, retained beyond the guild", + "server_blacklist": "global ban list; purging it would silently un-ban the guild", +} + // PurgeGuildData deletes all data associated with a guild from all tables. func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *zap.Logger) error { logger.Info("Starting guild data purge", zap.Uint64("guild_id", guildId)) - // Start a transaction for atomicity tx, err := d.BeginTx(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) @@ -19,97 +118,26 @@ func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *z defer tx.Rollback(ctx) - // Tables with direct guild_id column - // will be automatically deleted via ON DELETE CASCADE foreign key constraints - directGuildIdTables := []string{ - // Ticket-related child tables (must be deleted before tickets) - "archive_messages", - "auto_close_exclude", - "category_update_queue", - "close_reason", - "close_request", - "exit_survey_responses", - "first_response_time", - "participant", - "service_ratings", - "ticket_claims", - "ticket_last_message", - "ticket_members", - - // Tickets table and its counter - "tickets", - "guild_ticket_counters", - - // Panels table - "panels", - "multi_panels", - - // Support team related - "support_team", - - // Form-related - "forms", - - // Embed-related - "embeds", - - // Custom integration related - "custom_integration_secret_values", - "custom_integration_guilds", - - // Other guild-specific tables - "active_language", - "archive_channel", - "blacklist", - "channel_category", - "claim_settings", - "custom_colours", - "guild_metadata", - "legacy_premium_entitlement_guilds", - "naming_scheme", - "on_call", - "permissions", - "premium_guilds", - "role_blacklist", - "role_permissions", - "settings", - "staff_override", - "tags", - "ticket_limit", - "ticket_permissions", - "user_guilds", - "webhooks", - "welcome_messages", - "whitelabel_guilds", + for _, t := range guildPurgeIndirect { + if err := purgeTable(ctx, tx, logger, guildId, t.table, t.query); err != nil { + return err + } } - // Delete from tables with direct guild_id column - // Child tables are automatically deleted via CASCADE - for _, table := range directGuildIdTables { + for _, table := range guildPurgeTables { query := fmt.Sprintf(`DELETE FROM %s WHERE guild_id = $1`, table) - result, err := tx.Exec(ctx, query, guildId) - if err != nil { - logger.Error( - "Failed to delete from table", - zap.String("table", table), - zap.Uint64("guild_id", guildId), - zap.Error(err), - ) - return fmt.Errorf("failed to delete from %s: %w", table, err) + if err := purgeTable(ctx, tx, logger, guildId, table, query); err != nil { + return err } + } - rowsAffected := result.RowsAffected() - if rowsAffected > 0 { - logger.Info( - "Deleted rows from table", - zap.String("table", table), - zap.Uint64("guild_id", guildId), - zap.Int64("rows_deleted", rowsAffected), - ) + for _, table := range guildPurgeLegacy { + query := fmt.Sprintf(`DELETE FROM %s WHERE guild_id = $1`, table) + if err := purgeTable(ctx, tx, logger, guildId, table, query); err != nil { + return err } } - // Commit the transaction if err := tx.Commit(ctx); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } @@ -117,3 +145,42 @@ func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *z logger.Info("Successfully completed guild data purge", zap.Uint64("guild_id", guildId)) return nil } + +func purgeTable(ctx context.Context, tx pgx.Tx, logger *zap.Logger, guildId uint64, table, query string) error { + // A missing relation aborts the whole transaction, blocking every purge. + var exists bool + if err := tx.QueryRow(ctx, `SELECT to_regclass($1) IS NOT NULL`, table).Scan(&exists); err != nil { + return fmt.Errorf("failed to check existence of %s: %w", table, err) + } + + if !exists { + logger.Warn( + "Skipping table that no longer exists", + zap.String("table", table), + zap.Uint64("guild_id", guildId), + ) + return nil + } + + result, err := tx.Exec(ctx, query, guildId) + if err != nil { + logger.Error( + "Failed to delete from table", + zap.String("table", table), + zap.Uint64("guild_id", guildId), + zap.Error(err), + ) + return fmt.Errorf("failed to delete from %s: %w", table, err) + } + + if rowsAffected := result.RowsAffected(); rowsAffected > 0 { + logger.Info( + "Deleted rows from table", + zap.String("table", table), + zap.Uint64("guild_id", guildId), + zap.Int64("rows_deleted", rowsAffected), + ) + } + + return nil +} From 6edd0e9078b1a042f795110957f4539d64f84034 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:12:03 +0200 Subject: [PATCH 2/2] Bump common --- go.mod | 6 ++---- go.sum | 14 +++----------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 8bdfeb5..1bbb416 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,15 @@ go 1.22.0 toolchain go1.22.4 -replace github.com/TicketsBot-cloud/common => ../common +//replace github.com/TicketsBot-cloud/common => ../common require ( - github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e + github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4 github.com/google/uuid v1.6.0 github.com/jackc/pgtype v1.14.0 github.com/jackc/pgx v3.6.2+incompatible github.com/jackc/pgx/v4 v4.18.3 github.com/json-iterator/go v1.1.12 - github.com/sirupsen/logrus v1.9.0 go.uber.org/zap v1.27.1 ) @@ -30,6 +29,5 @@ require ( github.com/pkg/errors v0.9.1 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.28.0 // indirect - golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 581068e..4205d98 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98 h1:HwtXrqSv6y5E8mTnkOtISTaQr6dsK/lfUC8tUA6SzSU= -github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98/go.mod h1:iiZhl7w5DeTEGzAeq/hzani8+ILQz60g4JFkOy2vkuM= github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e h1:66mDH3lvotbWSgQXNVbssCvUgy6Yg1mqKl7RmEdr6y4= github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= +github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4 h1:zcPcJ+03xW9qalqzqllM6j0ZPdhuL+mrtGw8woLrAMo= +github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -111,8 +111,6 @@ github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXY github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= -github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -121,9 +119,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -174,11 +171,6 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=