Skip to content

PM-33527 - Don't leave orphaned event logs in storage - #7783

Closed
prograhamming wants to merge 51 commits into
mainfrom
dirt/pm-33527/server-db-combined
Closed

PM-33527 - Don't leave orphaned event logs in storage#7783
prograhamming wants to merge 51 commits into
mainfrom
dirt/pm-33527/server-db-combined

Conversation

@prograhamming

@prograhamming prograhamming commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-33527

📔 Objective

In order to stay within GDPR compliance when a organization wants to be deleted we need to remove the event logs from Azure Table Storage. This PR is the database changes needed in order to delete the event logs using jobs and long running processes.

prograhamming and others added 30 commits April 21, 2026 08:09
…:bitwarden/server into dirt/pm-33527/remove-orphaned-event-logs
…warden/server into dirt/pm-33527/db-orphaned-event-logs
Comment thread src/Infrastructure.EntityFramework/Dirt/Models/OrganizationDeleteTask.cs Dismissed
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
E Reliability Rating on New Code (required ≥ D)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@prograhamming
prograhamming marked this pull request as ready for review June 18, 2026 00:27
@prograhamming
prograhamming requested review from a team as code owners June 18, 2026 00:27
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the new OrganizationDeleteTask queue feature that durably enqueues a GDPR event-log cleanup when an organization is deleted, plus the Admin background job (CleanUpOrganizationEventsJob) that claims tasks under a SQL lease and purges Table Storage events in bounded, resumable batches. Dual-ORM parity (Dapper + EF migrations for MySQL/Postgres/Sqlite + MSSQL DbScripts), DI registration, transaction atomicity, error sanitization, and lease/concurrency handling were all examined. Unit and integration test coverage is thorough and aligns well with the code.

Code Review Details

No new findings. The two items below are already tracked in existing unresolved review threads and are not re-posted:

  • ⚠️ : EF DeleteManyByOrganizationIdAsync issues a single unbounded ExecuteDeleteAsync() while the Table Storage and Dapper paths batch. (existing thread)
    • src/Infrastructure.EntityFramework/Dirt/Repositories/EventRepository.cs
  • ♻️ : EF model OrganizationDeleteTask shares its name with its base Core.Dirt.Entities.OrganizationDeleteTask. (existing thread)
    • src/Infrastructure.EntityFramework/Dirt/Models/OrganizationDeleteTask.cs

Notes from validation:

  • The reordering of SaveChangesAsync() before CommitAsync() in the EF DeleteInternalAsync correctly persists tracked changes (org removal, sponsorship nulling, task insert) inside the transaction — an improvement over the prior ordering.
  • The job is gated on !SelfHosted, so it exercises the Table Storage IEventRepository, which is correctly bounded (maxBatchesPerCall) and resumable; this matches the job's loop-until-zero contract.
  • Microsoft.Extensions.Diagnostics.Testing added to test/Infrastructure.IntegrationTest is an already-approved dependency used across many test projects — routine, no AppSec action needed.

Comment on lines +55 to +62
public async Task<int> DeleteManyByOrganizationIdAsync(Guid organizationId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
return await dbContext.Events
.Where(e => e.OrganizationId == organizationId)
.ExecuteDeleteAsync();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Suggested: This EF implementation deletes every event for the organization in a single unbounded ExecuteDeleteAsync(), while the other implementations of DeleteManyByOrganizationIdAsync deliberately batch to keep transactions short — the Dapper stored procedure (Event_DeleteManyByOrganizationId) deletes in TOP(1000) chunks, and the Table Storage version caps work per call. For an organization with a very large Event history this single statement can produce a long-running transaction with lock escalation and transaction-log growth.

The cleanup job is cloud-only and currently runs against Table Storage, so this path isn't exercised today, but since it satisfies the same interface contract it's worth batching here too for consistency and safety if it's ever wired up. No change required if this method is intentionally a placeholder.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one as-is intentionally: Take() + ExecuteDeleteAsync doesn't translate across all three EF providers, and this path is only reachable on self-host EF deployments where a single set-based delete is acceptable. If it ever becomes a problem, the durable delete-task queue retries on failure, so a provider-specific batched implementation can be added later without contract changes.

@BTreston BTreston left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved for AC files

@@ -0,0 +1,45 @@
CREATE PROCEDURE [dbo].[OrganizationDeleteTask_ClaimNextPending]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the proc doesn't follow the T-SQL guidelines. This should be named something like OrganizationDeleteTask_UpdateClaimNextPending.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to OrganizationDeleteTask_UpdateClaimNextPending in PR #7910 (commit 3772c3a), matching its siblings (_UpdateCompleted / _UpdateError / _UpdateProgress). The migration also drops the old proc name idempotently so dev databases that ran the earlier version don't keep an orphaned copy.

BEGIN
DECLARE @OrganizationDeleteTaskDate DATETIME2(7) = COALESCE(@OrganizationDeleteTaskCreationDate, SYSUTCDATETIME())

INSERT INTO [dbo].[OrganizationDeleteTask]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why you don't just call OrganizationDeleteTask_Create here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changed shape in the follow-up PR #7910: the scalar task parameters were replaced with a table-valued parameter (OrganizationDeleteTaskArray) and a set-based INSERT … SELECT, so any number of task types can be enqueued atomically in the same transaction as the delete. The single-row OrganizationDeleteTask_Create proc no longer fits that shape — it's still used for the standalone create path.


WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION Event_DeleteManyByOrganizationId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This explicit transaction is unnecessary and potentially harmful. Since the only thing affected by the transaction in this case is the DELETE statement, it's already guaranteed to be atomic (it's a single statement). However, with the explicit transaction and no error handling, it's possible that SQL Server could error on the statement and leave the transaction uncommitted.

If you want to guarantee that all records for an org are deleted (or none in the event of a failure), you'd need to move the BEGIN/COMMIT outside of the WHILE loop, and add a TRY/CATCH block to handle any errors. See this for an example.

If you don't care about all or nothing, then the explicit transaction should be removed completely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — removed the explicit transaction completely in the follow-up PR #7910 (commit 3772c3a). Since each batched DELETE is a single atomic statement and the resumable delete-task design intentionally wants partial progress to persist, all-or-nothing semantics weren't needed.

@@ -0,0 +1,27 @@
CREATE PROCEDURE [dbo].[Event_DeleteManyByOrganizationId]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓Is this proc being added mainly for parity or do you expected self-host clients to run it? The main reason I ask is because the functionality does not match cloud (where looping is handled in code vs in the DB). The first call to the proc from code will cause this to loop to completion, and this may timeout for large customers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-host clients are expected to run it — cloud stays on the Table Storage implementation. Looping in the proc keeps the same contract as the other implementations ("delete until done" per call). On the timeout concern, PR #7910 mitigates it from a few directions: each batch is now a seek via the new IX_Event_OrganizationId filtered index rather than a table scan, the explicit transaction was removed so every batch auto-commits independently, and the caller uses a 3600s command timeout. If a very large customer still times out, progress persists and the durable delete-task queue simply re-claims and resumes on the next run.

DELETE TOP(@BatchSize)
FROM
[dbo].[Event]
WHERE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no index on the OrganizationId column, so this delete batch will cause thousands of table scans for large customers. Based on my previous question (parity vs actual use), you may want to add a filtered index on OrganizationId where OrganizationId IS NOT NULL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in PR #7910 (commit 3772c3a): a filtered index IX_Event_OrganizationId ON [dbo].[Event]([OrganizationId]) WHERE [OrganizationId] IS NOT NULL per your suggestion, plus the EF Core equivalent (unfiltered, since MySQL doesn't support filtered indexes — same approach as IX_Notification_OrganizationId / IX_SecurityTask_OrganizationId) with generated migrations for all three EF providers.

prograhamming added a commit that referenced this pull request Jul 13, 2026
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.
@Banrion

Banrion commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Superseded by #8182

@Banrion Banrion closed this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants