-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix: lock parent user row in user soft-delete guards #28546
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fix-user-cap-advisory-locks
Are you sure you want to change the base?
Changes from all commits
86668c8
d3befa7
60c6b21
dd747c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -253,6 +253,7 @@ func TestMetrics(t *testing.T) { | |
| mDB.EXPECT().DeleteOldProvisionerDaemons(gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().PurgeSoftDeletedUserResources(gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
|
|
@@ -307,6 +308,7 @@ func TestMetrics(t *testing.T) { | |
| mDB.EXPECT().DeleteOldProvisionerDaemons(gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().PurgeSoftDeletedUserResources(gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() | ||
| mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
|
|
@@ -3551,3 +3553,99 @@ func TestDeleteIdentifiedModuleCacheFiles(t *testing.T) { | |
| assertFileExists(late.ID, "archive inserted after the one-off pass") | ||
| assertCacheRef(lateTV, late.ID, true, "archive inserted after the one-off pass") | ||
| } | ||
|
|
||
| // TestPurgeSoftDeletedUserResources verifies the reaper removes child rows | ||
| // orphaned by a user soft-delete that predates the guard triggers and | ||
| // cleanup coverage (migration 000592 deliberately has no backfill), while a | ||
| // live user's rows survive. | ||
| // | ||
| //nolint:paralleltest // It uses LockIDDBPurge. | ||
| func TestPurgeSoftDeletedUserResources(t *testing.T) { | ||
| db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) | ||
| logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) | ||
| ctx := testutil.Context(t, testutil.WaitShort) | ||
|
|
||
| org := dbgen.Organization(t, db, database.Organization{}) | ||
| provider := dbgen.AIProvider(t, db, database.AIProvider{}) | ||
| group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID}) | ||
|
|
||
| liveUser := dbgen.User(t, db, database.User{}) | ||
| doomedUser := dbgen.User(t, db, database.User{}) | ||
|
|
||
| seed := func(userID uuid.UUID) { | ||
| _, err := sqlDB.ExecContext(ctx, ` | ||
| INSERT INTO api_keys (id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, scopes, allow_list) | ||
| VALUES ($1, 'reap-hash'::bytea, $2, now(), now() + interval '1 hour', now(), now(), 'password', '{}'::api_key_scope[], ARRAY['*']) | ||
| `, uuid.NewString(), userID) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, | ||
| `INSERT INTO user_links (user_id, login_type, linked_id) VALUES ($1, 'github', $2)`, | ||
| userID, "reap-link-"+userID.String()) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, ` | ||
| INSERT INTO user_secrets (id, user_id, name, description, value, env_name) | ||
| VALUES ($1, $2, 'reap-secret', '', 'value', 'REAP_SECRET') | ||
| `, uuid.New(), userID) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, ` | ||
| INSERT INTO user_skills (id, user_id, name, description, content) | ||
| VALUES ($1, $2, 'reap-skill', '', 'content') | ||
| `, uuid.New(), userID) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, ` | ||
| INSERT INTO user_ai_provider_keys (id, user_id, ai_provider_id, api_key) | ||
| VALUES ($1, $2, $3, 'reap-key') | ||
| `, uuid.New(), userID, provider.ID) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, ` | ||
| INSERT INTO organization_members (user_id, organization_id, created_at, updated_at) | ||
| VALUES ($1, $2, now(), now()) | ||
| `, userID, org.ID) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, | ||
| `INSERT INTO group_members (user_id, group_id) VALUES ($1, $2)`, | ||
| userID, group.ID) | ||
| require.NoError(t, err) | ||
| _, err = sqlDB.ExecContext(ctx, ` | ||
| INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros) | ||
| VALUES ($1, $2, 1000000) | ||
| `, userID, group.ID) | ||
| require.NoError(t, err) | ||
| } | ||
| seed(liveUser.ID) | ||
| seed(doomedUser.ID) | ||
|
|
||
| // Reconstruct pre-guard orphans: soft-delete with the cleanup trigger | ||
| // suppressed, so every child row survives. | ||
| dbtestutil.SoftDeleteUserKeepingRows(ctx, t, sqlDB, doomedUser.ID) | ||
|
|
||
| guardedTables := []string{"api_keys", "user_links", "user_secrets", "user_skills", "user_ai_provider_keys", "organization_members", "group_members", "user_ai_budget_overrides"} | ||
| countRows := func(table string, userID uuid.UUID) int { | ||
| var count int | ||
| //nolint:gosec // The table name comes from the fixed list above. | ||
| err := sqlDB.QueryRowContext(ctx, | ||
| `SELECT count(*) FROM `+table+` WHERE user_id = $1`, userID).Scan(&count) | ||
| require.NoError(t, err) | ||
| return count | ||
| } | ||
| for _, table := range guardedTables { | ||
| require.Equal(t, 1, countRows(table, doomedUser.ID), "pre-purge: %s orphan must exist", table) | ||
| } | ||
|
|
||
| // The initial tick runs the purge immediately. | ||
| closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(quartz.NewReal())) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-75]
|
||
| defer closer.Close() | ||
|
|
||
| require.Eventually(t, func() bool { | ||
| for _, table := range guardedTables { | ||
| if countRows(table, doomedUser.ID) != 0 { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| }, testutil.WaitShort, testutil.IntervalFast, "all orphaned child rows of the soft-deleted user must be reaped") | ||
|
|
||
| for _, table := range guardedTables { | ||
| require.Equal(t, 1, countRows(table, liveUser.ID), "post-purge: %s row for the live user must survive", table) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package dbtestutil | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "testing" | ||
|
|
||
| "github.com/google/uuid" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // SoftDeleteUserKeepingRows marks the user deleted while suppressing the | ||
| // delete_deleted_user_resources cleanup trigger, so the user's child rows | ||
| // (api_keys, user_links, and the other guarded tables) survive. This | ||
| // reconstructs the orphaned-row state that could exist before migration | ||
| // 000592 closed the insert-vs-soft-delete race (the insert guards now also | ||
| // reject new rows for deleted users, so the state can only be constructed | ||
| // this way). Tests use it to prove such legacy rows stay inert. | ||
| func SoftDeleteUserKeepingRows(ctx context.Context, t testing.TB, sqlDB *sql.DB, userID uuid.UUID) { | ||
| t.Helper() | ||
| // One transaction: transactional DDL keeps the disabled trigger | ||
| // invisible to concurrent sessions (which may share this database under | ||
| // CODER_PG_CONNECTION_URL) and rolls the disable back on failure. | ||
| tx, err := sqlDB.BeginTx(ctx, nil) | ||
| require.NoError(t, err) | ||
| committed := false | ||
| defer func() { | ||
| if !committed { | ||
| _ = tx.Rollback() | ||
| } | ||
| }() | ||
| _, err = tx.ExecContext(ctx, `ALTER TABLE users DISABLE TRIGGER trigger_update_users`) | ||
| require.NoError(t, err) | ||
| _, err = tx.ExecContext(ctx, `UPDATE users SET deleted = true WHERE id = $1`, userID) | ||
| require.NoError(t, err) | ||
| _, err = tx.ExecContext(ctx, `ALTER TABLE users ENABLE TRIGGER trigger_update_users`) | ||
| require.NoError(t, err) | ||
| require.NoError(t, tx.Commit()) | ||
| committed = true | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2 [CRF-70] The reaper is the only delete in
purgeTickwith no batch bound, and it shares the single purge transaction, so a large first pass or one lost deadlock rolls back every retention purge in the tick. (Pariston, Zoro, Killua P2; Knuckle, Takumi, Mafuuu P3)Every sibling delete in this
InTxcarries aLimitCount(10000 / 1000).PurgeSoftDeletedUserResourcesisDELETE ... WHERE user_id IN (SELECT id FROM users WHERE deleted)across eight tables for every soft-deleted user, in one statement, atLevelDefaultwith nostatement_timeout, on the forced initial tick at startup. The first pass on an old deployment (pre-000492 users carry orphanedorganization_membersplus transitivegroup_members/user_ai_budget_overrides) is exactly the largest. Two consequences: no partial progress (all-or-nothing statement; an interrupted large backlog never converges, re-doing the whole thing each tick), and blast radius (a reaper failure rolls back the audit-log, connection-log, and chat purges already done in the same tick, so all retention cleanup stalls and disk grows). The query comment's "a lost deadlock surfaces as a failed purge cycle and is retried" understates this. Batch by user per tick (LIMITondoomed_users, loop/defer the rest) like the siblings, or run the reaper in its own transaction so its failure cannot roll back unrelated purges.