Dirt/pm 33527/multi delete task types - #7910
Conversation
| foreach (var deleteTaskType in deleteTaskTypes) | ||
| { | ||
| var deleteTask = new Dirt.Models.OrganizationDeleteTask | ||
| { | ||
| OrganizationId = organization.Id, | ||
| TaskType = deleteTaskType, | ||
| CreationDate = creationDate, | ||
| RevisionDate = creationDate, | ||
| }; | ||
| deleteTask.SetNewId(); | ||
| await dbContext.OrganizationDeleteTasks.AddAsync(deleteTask); | ||
| } |
There was a problem hiding this comment.
Done in 12d38fe — the mapping is now a Select projection passed to AddRangeAsync, matching the Dapper implementation's existing .Select(...).ToList() approach.
|
|
||
| namespace Bit.Infrastructure.EntityFramework.Dirt.Models; | ||
|
|
||
| public class OrganizationDeleteTask : Core.Dirt.Entities.OrganizationDeleteTask |
There was a problem hiding this comment.
Won't fix — sharing the Core entity's class name (in a different namespace) is the established convention for EF models throughout this codebase, e.g. EF.Vault.Models.Cipher : Core.Vault.Entities.Cipher and EF.AdminConsole.Models.Organization : Core.AdminConsole.Entities.Organization. Renaming this one model would make it inconsistent with that pattern and the AutoMapper profile conventions.
| @OrganizationDeleteTaskId UNIQUEIDENTIFIER = NULL, | ||
| @OrganizationDeleteTaskType TINYINT = NULL, | ||
| @OrganizationDeleteTaskCreationDate DATETIME2(7) = NULL, | ||
| @OrganizationDeleteTasks [dbo].[OrganizationDeleteTaskArray] READONLY |
There was a problem hiding this comment.
❓ Should @OrganizationDeleteTasks argument default to NULL?
@OrganizationDeleteTaskId, @OrganizationDeleteTaskType, @OrganizationDeleteTaskCreationDate arguments and usage since they are replaced by @OrganizationDeleteTasks
There was a problem hiding this comment.
Done in 31551b3 — the three single-task arguments and their legacy enqueue branch are removed; the proc now takes only @Id plus the TVP.
On defaulting to NULL: a TVP can't take an explicit default (assigning NULL to a table type is an operand type clash), and it doesn't need one — per the CREATE PROCEDURE docs, a TVP omitted at call time passes an empty table, which the IF EXISTS guard already handles.
Note this migration file no longer exists — per the other thread it was folded into the base PR's migration, now named 2026-07-14_03_AlterOrganizationDeleteByIdEnqueueDeleteTask.sql.
| END | ||
| GO | ||
|
|
||
| CREATE OR ALTER PROCEDURE [dbo].[Organization_DeleteById] |
There was a problem hiding this comment.
There was a problem hiding this comment.
Done in 31551b3 — this file is deleted and the TVP change is folded into #7783's existing migration. The stack's migrations were subsequently renamed (a7d6599) to sort after main's latest, so the combined script is now 2026-07-14_03_AlterOrganizationDeleteByIdEnqueueDeleteTask.sql. Since this PR now edits migrations owned by #7783, the two need to merge together (or this one rebases after #7783 lands) — noted in the PR description.
| @OrganizationDeleteTaskId UNIQUEIDENTIFIER = NULL, | ||
| @OrganizationDeleteTaskType TINYINT = NULL, | ||
| @OrganizationDeleteTaskCreationDate DATETIME2(7) = NULL | ||
| @OrganizationDeleteTaskCreationDate DATETIME2(7) = NULL, | ||
| @OrganizationDeleteTasks [dbo].[OrganizationDeleteTaskArray] READONLY |
There was a problem hiding this comment.
❓ Should @OrganizationDeleteTasks default to NULL
There was a problem hiding this comment.
Done in 31551b3 — the three single-task parameters are removed in preference of @OrganizationDeleteTasks.
On = NULL: TVPs can't be given an explicit default and don't need one — they're implicitly optional. Per the CREATE PROCEDURE docs: "If a procedure contains table-valued parameters, and the parameter is missing in the call, an empty table is passed in." The IF EXISTS (SELECT 1 FROM @OrganizationDeleteTasks) guard handles that case.
Remove the legacy single-task scalar params (@OrganizationDeleteTaskId, @OrganizationDeleteTaskType, @OrganizationDeleteTaskCreationDate) and the legacy single-task enqueue branch from Organization_DeleteById, keeping only the @OrganizationDeleteTasks table-valued parameter. The single-task version (PR #7783) has not merged to main, so no rolling-deployment backwards compat is needed. Fold the multi-task change into the existing #7783 migration (2026-06-17_02) and remove the redundant new 2026-06-30_00 migration.
Map deleteTaskTypes into OrganizationDeleteTask entities via a LINQ Select projection and enqueue them with AddRangeAsync, matching the Dapper repository's pattern instead of mapping inside a foreach loop.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dirt/pm-33527/server-db-combined #7910 +/- ##
====================================================================
- Coverage 65.72% 65.71% -0.01%
====================================================================
Files 2214 2215 +1
Lines 97929 97979 +50
Branches 8828 8830 +2
====================================================================
+ Hits 64361 64391 +30
- Misses 31346 31366 +20
Partials 2222 2222 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the generalization of the organization-delete cleanup path from a single events-cleanup job to a type-dispatched No new blocking findings. Prior reviewer feedback (JSON-instead-of-TVP, single-task argument removal, migration re-dating, Code Review DetailsNo findings at or above the confidence threshold for posting. Notes considered and intentionally not raised as inline findings:
|
| builder | ||
| .HasIndex(e => e.OrganizationId) | ||
| .IsClustered(false) | ||
| .HasDatabaseName("IX_Event_OrganizationId"); |
There was a problem hiding this comment.
♻️ DEBT: IX_Event_OrganizationId diverges between ORMs — MSSQL is filtered, EF is not.
Details
The MSSQL definition (Event.sql:34-35 and the 2026-07-14_04 migration) creates a filtered index:
CREATE NONCLUSTERED INDEX [IX_Event_OrganizationId]
ON [dbo].[Event]([OrganizationId] ASC) WHERE [OrganizationId] IS NOT NULL;The EF configuration here (and the three generated EF migrations) creates a non-filtered index — no HasFilter. The migration comment states the filter is intentional ("keeps the index small since many events are user-scoped"), so the EF providers miss that optimization and the four supported databases end up with different index shapes.
PostgreSQL and SQLite support partial indexes, so parity is achievable there:
builder
.HasIndex(e => e.OrganizationId)
.IsClustered(false)
.HasFilter("[OrganizationId] IS NOT NULL")
.HasDatabaseName("IX_Event_OrganizationId");MySQL does not support partial indexes, so this is a genuine per-provider limitation rather than a fix you can apply everywhere. Not a correctness issue (the index is still used), but worth aligning where the ORMs allow it, or noting explicitly why they differ.
Reference: dual-ORM parity — a schema change should be reflected consistently across both tracks.
There was a problem hiding this comment.
This follows the repo's existing precedent: merged PR #7690 added IX_Event_OrganizationIdSendIdDate filtered on MSSQL but unfiltered on the EF side, with a comment explaining that MySQL doesn't support filtered indexes. In 6c95b9c I added the same explanatory comment to this index's EF configuration rather than introducing per-provider HasFilter branching for one index — keeping the three EF providers uniform matches how the codebase already handles this divergence.
| GO | ||
| CREATE NONCLUSTERED INDEX [IX_Event_OrganizationId] | ||
| ON [dbo].[Event]([OrganizationId] ASC) WHERE [OrganizationId] IS NOT NULL; | ||
|
|
There was a problem hiding this comment.
Merged PR 7690 adds a new column SendId and new index to this table as well, so your Event.sql is stale.
There was a problem hiding this comment.
Done in 6c95b9c — Event.sql now matches main's post-#7690 shape (SendId column plus the filtered IX_Event_OrganizationIdSendIdDate index) with this PR's IX_Event_OrganizationId added after it. The diff against the stacked base branch will show the SendId lines as additions since that branch predates #7690, but the file content now agrees with main so the eventual merge won't clobber it.
| @@ -0,0 +1,4 @@ | |||
| CREATE TYPE [dbo].[OrganizationDeleteTaskArray] AS TABLE ( | |||
There was a problem hiding this comment.
New datatypes should not by created. If you need to pass an object, JSON is the preferred type.
There was a problem hiding this comment.
Done in 6c95b9c — the OrganizationDeleteTaskArray type is removed. Organization_DeleteById now takes @OrganizationDeleteTasks NVARCHAR(MAX) = NULL (a JSON array of { Id, TaskType, CreationDate }) parsed with OPENJSON ... WITH, and the Dapper repository serializes with JsonSerializer like the other JSON-taking procs. The migration script also drops the type for anyone who ran the earlier revision.
| @OrganizationDeleteTaskId UNIQUEIDENTIFIER = NULL, | ||
| @OrganizationDeleteTaskType TINYINT = NULL, | ||
| @OrganizationDeleteTaskCreationDate DATETIME2(7) = NULL | ||
| @OrganizationDeleteTasks [dbo].[OrganizationDeleteTaskArray] READONLY |
There was a problem hiding this comment.
Per my previous comment, this should be a VARCHAR(MAX)/NVARCHAR(MAX) and processed with JSON_VALUE/OPENJSON as needed.
There was a problem hiding this comment.
Done in 6c95b9c — the parameter is now NVARCHAR(MAX) = NULL and the tasks are read via OPENJSON(@OrganizationDeleteTasks) WITH ([Id] UNIQUEIDENTIFIER, [TaskType] TINYINT, [CreationDate] DATETIME2(7)).
| @@ -9,8 +9,6 @@ BEGIN | |||
|
|
|||
| WHILE @BatchSize > 0 | |||
There was a problem hiding this comment.
I don't generally focus too much on the C# code but my Claude review helper noted this about this procedure (not sure if the design is intentional):
The job/repository layer was correctly redesigned (bounded run budget, lease refresh per batch, explicit "bounded per call" contract), but the only implementation still calls the unchanged Event_DeleteManyByOrganizationId, which loops internally until the entire org's events are deleted (commandTimeout: 3600). A large org can still blow past the 10-minute lease and get reclaimed by a concurrent run.
There was a problem hiding this comment.
Good catch — fixed in 6c95b9c. Event_DeleteManyByOrganizationId now takes @MaxRows INT = 50000 and stops once that cap is hit, returning the count so the job calls it again after refreshing the lease via its per-batch progress update; the Dapper commandTimeout drops from 3600 to 300 accordingly. The EF implementation had the same problem (ExecuteDeleteAsync over the whole org) and is now bounded to 1000 rows per call. A large org therefore never holds a single call longer than one bounded chunk, and the 4-minute run budget / 10-minute lease math holds.
🎟️ Tracking & 📔 Objective
This PR is stacked on top of this pull request in order to make the original ask more flexible with different types.