[PM-33527] Implement Organization Delete Tasks with Events - #8182
[PM-33527] Implement Organization Delete Tasks with Events#8182Banrion wants to merge 26 commits into
Conversation
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the full change set: the Code Review DetailsNo blocking findings. Verified during this pass:
One minor, non-blocking note: on MySQL/Postgres/SQLite the new unfiltered |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8182 +/- ##
==========================================
+ Coverage 63.41% 68.08% +4.66%
==========================================
Files 2339 2345 +6
Lines 101534 101885 +351
Branches 9179 9198 +19
==========================================
+ Hits 64385 69365 +4980
+ Misses 34935 30188 -4747
- Partials 2214 2332 +118 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // Azure Table Storage caps a single transaction at 100 ops; the outer cap | ||
| // bounds work per call so the background job can interleave other orgs. | ||
| const int batchSize = 100; | ||
| const int maxBatchesPerCall = 18_000; | ||
| // Bound concurrent transaction submissions so a large organization doesn't | ||
| // fan out thousands of simultaneous requests and get throttled. | ||
| const int maxConcurrentBatches = 8; |
There was a problem hiding this comment.
Details and fix
OrganizationDeleteTasksJob only refreshes the lease between handler calls — UpdateProgressAsync (which writes RevisionDate) runs after DeleteBatchAsync returns. So the per-call bound has to stay comfortably under LeaseDurationMinutes (10).
The SQL paths respect that: MSSQL is capped at @MaxRows = 50000 with a 300s commandTimeout, EF at 1,000 rows. This path allows 18_000 * 100 = 1,800,000 entities in one call — 36× the MSSQL bound, with no time bound at all.
For the multi-million-event organization that motivated this PR, that call plausibly exceeds 10 minutes (~2,250 sequential rounds at maxConcurrentBatches = 8, plus ~1,800 query page round-trips interleaved). Once it does:
- The 5-minute trigger fires a fresh
OrganizationDeleteTasksJob(the job has no[DisallowConcurrentExecution], so this happens even in a single Admin instance). ClaimNextPendingAsyncseesRevisionDateolder than the stale threshold and reclaims the same task.- Both runs enumerate and delete the same partition. Whichever batch races loses gets a 404 from
SubmitTransactionAsync→TableTransactionFailedException→UpdateErrorAsync. - Five such collisions hit
MaxFailureCountand the task is abandoned — the GDPR purge stops permanently (logged atError, but incomplete).
Suggested fix — bound this call to something comparable to the SQL paths, e.g.:
const int maxBatchesPerCall = 500; // 50,000 entities, matching Event_DeleteManyByOrganizationId's @MaxRowsThe job already loops until an empty batch is returned, so a smaller bound only means more calls — and each one refreshes the lease. Adding [DisallowConcurrentExecution] to the job would also close the single-instance case, though not the multi-instance one.
This is also the one path with no automated coverage, as the PR notes, so the bound is worth setting conservatively.
🎟️ Tracking
PM-33527
Supersedes #7783 and #7910, opened by a teammate who has since moved teams. This branch carries both change sets rebased onto current
main, with all reviewer feedback from both applied for cloud and self-host environments.📔 Objective
Deleting an organization removes its database rows but leaves its event logs behind in storage, so personal data survives the deletion indefinitely. Product identified this as one of three storage-retention gaps for a departing Tier 1 customer (attachments, Sends, event logs) that were previously cleaned up by hand.
The purge cannot run inline with the delete — an organization can have millions of events, and the work has to survive process restarts. So this adds a durable queue:
Organization_DeleteByIdwrites oneOrganizationDeleteTaskrow per cleanup type in the same transaction as the delete, meaning the obligation can never be lost if the deletion commits. An Admin background job then claims tasks under a lease and purges in bounded, resumable batches.Dispatch is by task type, so another team can add a cleanup (attachments, Sends) by adding an enum value and registering a handler — no job changes.
Runs on every deployment
IEventRepositoryresolves to Azure Table Storage on cloud and to the SQL-backed Dapper/EF implementations on self-host, so all three purge paths are live:TableStorage.EventRepositoryEventdatabase tableEvent_DeleteManyByOrganizationIdvia DapperEventtable, via EFExecuteDeletevia EFProduct confirmed self-host should get the same cleanup. Nothing purges those rows today — there is no cascade from
OrganizationtoEventand no retention job — so self-host organizations orphan their events on delete just as cloud does.Dual-ORM coverage
OPENJSON, EFAddRangeAsyncMSSQL claims in one statement with
WITH (UPDLOCK, READPAST)+OUTPUT, which has no EF Core translation. EF uses optimistic concurrency instead: read the oldest claimable task, thenExecuteUpdateconditioned on it still being claimable, so a worker that loses the race updates zero rows and moves to the next candidate. Bounded at 3 attempts. All 8 queue tests plus the 3 purge tests run against SQL Server, Postgres, MySQL and SQLite.Bounded per call
Every implementation deletes a bounded amount and returns the count; the job loops until it gets 0, refreshing the lease between calls. Run budget 4 minutes against a 10-minute lease, triggered every 5 minutes.
@MaxRows = 50000inTOP(1000)batches,commandTimeout300sA task is abandoned after
MaxFailureCount(5) failures. That transition now logs atError, because otherwise a cleanup that has permanently stopped retrying would be invisible — individual failures surface through the job's rethrow, but the stop does not.Also included
OrganizationRepository:SaveChangesAsync()ran afterCommitAsync(), so the sponsorship nulling and organization removal were landing outside the transaction.pm-33527-organization-event-cleanup.Notes for reviewers
Organization_DeleteByIdtakes a JSON parameter, not a table type, per the T-SQL guidance against new user-defined types. ItsNOT NULLcolumns intentionally have noISNULLfallbacks; the inline comment explains why the rolling-deployment hazard that guidance protects against cannot occur here, since the parameter, the JSON contract and the table all ship together.OrganizationDeleteTaskrows are never deleted. Completed rows are kept deliberately, as evidence the GDPR cleanup ran.LastErrorstores an exception type and sanitized Azure status codes only — neverex.Message, which can embed row-key identifiers.IX_Event_OrganizationIdis filtered on MSSQL and unfiltered on EF, matching the precedent set byIX_Event_OrganizationIdSendIdDatein [PM-37944] Differentiate Send events according to domain #7690: MySQL does not support filtered indexes.context.CancellationTokenis inert in practice.InterruptJobsOnShutdownis never set andQuartz defaults it to
false, so executing jobs are not signalled on shutdown. The cancellation handling here is defensive; an interrupted purge is recovered by lease expiry and reclaim.Known gap
The Table Storage purge — cloud's actual path, and the one carrying the batching, concurrency throttle and error-drain logic — has no automated coverage; the harness that runs the SQL tests cannot reach it.