Skip to content

PM-33527 Database SQL scripts#7616

Open
prograhamming wants to merge 10 commits into
mainfrom
dirt/pm-33527/db-orphaned-event-logs
Open

PM-33527 Database SQL scripts#7616
prograhamming wants to merge 10 commits into
mainfrom
dirt/pm-33527/db-orphaned-event-logs

Conversation

@prograhamming
Copy link
Copy Markdown
Contributor

@prograhamming prograhamming commented May 12, 2026

🎟️ 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.

📸 Screenshots

@prograhamming prograhamming changed the title PM-33527 initial commit PM-33527 Database SQL scripts May 12, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 12, 2026

Codecov Report

❌ Patch coverage is 0% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.22%. Comparing base (d5bd9f3) to head (7819fe4).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...Repositories/OrganizationEventCleanupRepository.cs 0.00% 43 Missing ⚠️
src/Core/Dirt/Entities/OrganizationEventCleanup.cs 0.00% 10 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7616      +/-   ##
==========================================
+ Coverage   59.84%   64.22%   +4.38%     
==========================================
  Files        2121     2123       +2     
  Lines       93464    93517      +53     
  Branches     8292     8292              
==========================================
+ Hits        55929    60063    +4134     
+ Misses      35548    31385    -4163     
- Partials     1987     2069      +82     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@prograhamming prograhamming marked this pull request as ready for review May 13, 2026 04:24
@prograhamming prograhamming requested review from a team as code owners May 13, 2026 04:24
@Banrion Banrion added the ai-review Request a Claude code review label May 14, 2026
@Banrion
Copy link
Copy Markdown
Contributor

Banrion commented May 14, 2026

@claude Please add a review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 14, 2026

Claude finished @Banrion's task in 2m 56s —— View job


Code Review — PM-33527 Database SQL scripts

  • Read changed files
  • Analyze SQL scripts and migration
  • Analyze C# entity and repository
  • Post review feedback

Thanks for the GDPR cleanup scaffolding. The DDL/sproc/Dapper repo look coherent on their own, but there are gaps before this is wireable into the worker. Findings below grouped by severity.


🔴 Blocking

1. Missing EF Core implementation (dual‑ORM strategy)
Bitwarden supports MSSQL (Dapper) and PostgreSQL / MySQL / SQLite via EF Core. This PR adds only the Dapper side. To be deployable to all supported databases you also need:

  • src/Infrastructure.EntityFramework/Dirt/Models/OrganizationEventCleanup.cs
  • src/Infrastructure.EntityFramework/Dirt/Configurations/OrganizationEventCleanupEntityTypeConfiguration.cs
  • src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationEventCleanupRepository.cs
  • A DbSet<OrganizationEventCleanup> entry on DatabaseContext
  • Generated migrations under util/MySqlMigrations/Migrations, util/PostgresMigrations/Migrations, util/SqliteMigrations/Migrations

See the precedent set by OrganizationApplication/OrganizationReport.

2. DI registration missing
Repository is not registered. It must be added to both:

  • src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs (around line 81 where the other Dirt repos register)
  • src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs (around line 118)

Without this, IOrganizationEventCleanupRepository cannot be resolved at runtime.

3. ReadNextPending doesn't atomically claim work
OrganizationEventCleanup_ReadNextPending.sql:1-14

SELECT TOP 1 * FROM [dbo].[OrganizationEventCleanup]
WHERE [CompletedAt] IS NULL
ORDER BY [QueuedAt] ASC

If more than one worker instance polls (which is the normal case in App Services / k8s), every worker will pick the same row. MarkStarted uses COALESCE([StartedAt], SYSUTCDATETIME()) so it's idempotent — but that also means it does nothing to gate concurrent processors. Two workers will then run the deletion loop for the same organization in parallel.

Recommended fix — atomically claim and return in one statement:

UPDATE TOP (1) c
SET    [StartedAt] = SYSUTCDATETIME(),
       [LastProgressAt] = SYSUTCDATETIME()
OUTPUT inserted.*
FROM   [dbo].[OrganizationEventCleanup] c WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE  [CompletedAt] IS NULL
  AND  [StartedAt]   IS NULL
ORDER BY [QueuedAt] ASC

If you do want a separate "peek" + "start", at minimum use WITH (UPDLOCK, READPAST, ROWLOCK) and filter StartedAt IS NULL so a started job is skipped.


🟠 Important

4. No max-attempts / terminal failure handling
OrganizationEventCleanup_RecordError.sql increments Attempts forever. Without a MaxAttempts cutoff or a terminal "Failed" state, a permanently-broken row blocks the queue head and burns retries indefinitely. Consider either a FailedAt/Status column or a MaxAttempts constant the read-side filters on.

5. No retry backoff
After an error, ReadNextPending will immediately re-surface the same row at the head of the queue (since QueuedAt doesn't change). Workers will hot-loop on a failing org. Consider a NextAttemptAt / NotBefore column populated by RecordError with exponential backoff.

6. Missing tests
.claude/CLAUDE.md explicitly says: "ALWAYS add unit tests (with mocking) for any new feature development." No tests added for the new repository. At minimum, add Dapper repository tests under test/Infrastructure.IntegrationTest/... similar to the other Dirt repositories.

7. Event.OrganizationId filtered index — deployment risk
2026-05-12_00_AddOrganizationEventCleanup.sql:30-35 creates IX_Event_OrganizationId on what is one of the largest tables in production. On a busy SQL Server this needs to be created with WITH (ONLINE = ON) (Enterprise) and ideally during a maintenance window. Please confirm with DBA before merging and consider:

CREATE NONCLUSTERED INDEX [IX_Event_OrganizationId]
    ON [dbo].[Event]([OrganizationId] ASC)
    WHERE [OrganizationId] IS NOT NULL
    WITH (ONLINE = ON, MAXDOP = 4);

Also: the existing IX_Event_DateOrganizationIdUserId already leads with Date then OrganizationId — verify that an OrganizationId-led range scan is actually needed by your cleanup query and isn't already adequately served (e.g., via residual predicate on the existing index).


🟡 Minor / Style

8. SELECT * in stored procedure
OrganizationEventCleanup_ReadNextPending.sql:7 — repo convention is to list columns explicitly so adding a column doesn't accidentally change the returned shape.

9. Filtered index would be cheaper
Both queries on OrganizationEventCleanup filter [CompletedAt] IS NULL. Once jobs complete, they bloat the index for no reason. Consider:

CREATE NONCLUSTERED INDEX [IX_OrganizationEventCleanup_Pending]
    ON [dbo].[OrganizationEventCleanup]([QueuedAt] ASC)
    WHERE [CompletedAt] IS NULL;

10. Attempts column name is ambiguous
It's only incremented in RecordError, so it actually counts failures, not attempts. Either rename to FailureCount or also increment in MarkStarted to match the name.

11. SetNewId() not invoked by CreateAsync
OrganizationEventCleanupRepository.cs:17-24 trusts the caller to set cleanup.Id. Most other Dapper repos either call SetNewId() in CreateAsync if the id is empty, or default it in the stored procedure. Worth aligning so a caller can't insert Guid.Empty.

12. QueuedAt set client-side, others server-side
QueuedAt is passed in from C#, but StartedAt/LastProgressAt/CompletedAt come from SYSUTCDATETIME(). For clock-skew safety and consistency, consider defaulting QueuedAt server-side too (DEFAULT SYSUTCDATETIME()) or sourcing it from SYSUTCDATETIME() in the Create sproc.

13. GDPR retention question
The objective is GDPR org deletion. OrganizationEventCleanup retains OrganizationId after the org is gone (effectively an "this org was deleted on date X" audit record). That's usually fine, but worth confirming with privacy that the retention policy on this audit row matches the rest of the GDPR deletion contract — e.g., do you want to purge completed rows after N days?

14. SonarCloud flagged 2 new issues on the Sonar dashboard — please review.


Summary

The shape of the queue table is reasonable, but as written this PR can't ship: there is no EF Core implementation for non-MSSQL providers, the repository isn't registered with DI, and ReadNextPending will hand the same row to every concurrent worker. After those three are addressed, the retry/backoff and test gaps are the next things to tackle.
• Branch: dirt/pm-33527/db-orphaned-event-logs

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

This PR introduces the SQL Server schema, stored procedures, Dapper repository, and entity for an OrganizationEventCleanup work queue used to delete events on organization removal. Consumer code (background job, DI wire-up) is intentionally out of scope per the PR description. Review focused on the dual-ORM contract, deploy-time risk on the Event table, and the concurrency contract of the new queue.

Code Review Details
  • ⚠️ : Missing Entity Framework Core implementation breaks the dual-ORM contract for Postgres/MySQL/SQLite
    • src/Core/Dirt/Repositories/IOrganizationEventCleanupRepository.cs:13
  • ⚠️ : OrganizationEventCleanupRepository is not registered in DapperServiceCollectionExtensions
    • src/Infrastructure.Dapper/Dirt/Repositories/OrganizationEventCleanupRepository.cs:15
  • ⚠️ : OrganizationEventCleanup_ReadNextPending does not atomically claim work; concurrent workers will dequeue the same row
    • src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_ReadNextPending.sql:13
  • ⚠️ : Building IX_Event_OrganizationId without ONLINE = ON will block writes on dbo.Event during deploy
    • util/Migrator/DbScripts/2026-05-12_00_AddOrganizationEventCleanup.sql:30-35
  • 🎨 : CreateAsync does not call SetNewId(); callers must remember to set Id or hit PK collisions
    • src/Infrastructure.Dapper/Dirt/Repositories/OrganizationEventCleanupRepository.cs:17-24

Additional one-line observations (not posted inline):

  • No unit or integration tests accompany this change; per CLAUDE.md, new functionality requires tests with mocking. Integration repository tests for the new sproc set would catch the race condition and SetNewId issues above. Consider adding OrganizationEventCleanupRepositoryTests under test/Infrastructure.IntegrationTest.
  • The PR objective references GDPR compliance for events in Azure Table Storage, but the schema added here only tracks/deletes events from the SQL dbo.Event table. Worth confirming whether a parallel Azure Table Storage cleanup path is planned and that the same OrganizationEventCleanup queue will drive both.
  • LastError NVARCHAR(MAX) will store whatever the consumer passes in; recommend documenting that consumers must scrub raw exception messages before calling RecordErrorAsync so connection strings, parameter values, or event fragments cannot land in this column (zero-knowledge / no-PII-in-logs invariant).

Task IncrementProgressAsync(Guid id, long delta);
Task RecordErrorAsync(Guid id, string message);
Task MarkCompletedAsync(Guid id);
}
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.

⚠️ IMPORTANT: Missing Entity Framework Core implementation for dual-ORM strategy.

Details and fix

Bitwarden's data access layer requires both Dapper (MSSQL) and EF Core (PostgreSQL/MySQL/SQLite) implementations for any new repository interface unless the feature is explicitly EF-only. Only the Dapper implementation exists in this PR. Self-hosted instances on Postgres/MySQL/SQLite cannot resolve IOrganizationEventCleanupRepository once a consumer is wired in.

Required additions:

  • src/Infrastructure.EntityFramework/Dirt/Models/OrganizationEventCleanup.cs (entity)
  • src/Infrastructure.EntityFramework/Dirt/Configurations/OrganizationEventCleanupEntityTypeConfiguration.cs
  • src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationEventCleanupRepository.cs
  • DatabaseContext.OrganizationEventCleanups DbSet
  • EF migrations under util/Migrator/PostgresMigrations, MySqlMigrations, SqliteMigrations

Reference: Database migrations (EF)

{
public OrganizationEventCleanupRepository(GlobalSettings globalSettings)
: base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
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.

⚠️ IMPORTANT: Repository is not registered with the DI container.

Details and fix

OrganizationEventCleanupRepository is not added to DapperServiceCollectionExtensions.AddDapperRepositories (or the EF equivalent). Any future consumer that injects IOrganizationEventCleanupRepository will fail at runtime with an unresolved-dependency error.

Add in src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs:

services.AddSingleton<IOrganizationEventCleanupRepository, OrganizationEventCleanupRepository>();

And the equivalent registration in EntityFrameworkServiceCollectionExtensions.AddPasswordManagerEFRepositories once the EF implementation lands.

@sonarqubecloud
Copy link
Copy Markdown

WHERE
[CompletedAt] IS NULL
ORDER BY
[QueuedAt] ASC
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.

⚠️ IMPORTANT: ReadNextPending does not atomically claim work — concurrent workers will dequeue the same row.

Details and fix

The procedure returns the oldest non-completed row but does not mark it claimed. Two workers polling at the same time will each receive the same OrganizationEventCleanup row, both will call MarkStarted, and both will issue DELETE against the same organization's events in parallel — wasteful work, contention on the Event table, and inflated Attempts/EventsDeletedCount accounting.

The presence of StartedAt, Attempts, and LastError columns implies multi-worker retry semantics. Without atomic claim, those guarantees are unenforceable.

Suggested approach using READPAST + UPDLOCK + OUTPUT:

UPDATE TOP (1) [dbo].[OrganizationEventCleanup] WITH (READPAST, UPDLOCK, ROWLOCK)
SET [StartedAt] = COALESCE([StartedAt], SYSUTCDATETIME()),
    [LastProgressAt] = SYSUTCDATETIME()
OUTPUT INSERTED.*
WHERE [Id] = (
    SELECT TOP 1 [Id]
    FROM [dbo].[OrganizationEventCleanup] WITH (READPAST, UPDLOCK, ROWLOCK)
    WHERE [CompletedAt] IS NULL
      AND ([StartedAt] IS NULL OR [LastProgressAt] < DATEADD(MINUTE, -<lease>, SYSUTCDATETIME()))
    ORDER BY [QueuedAt] ASC
);

This also lets you reclaim stale leases when a worker dies mid-run. If a single-worker model is intended instead, that constraint should be documented and enforced at the consumer layer.

Comment on lines +30 to +35
IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_Event_OrganizationId' AND object_id = OBJECT_ID('[dbo].[Event]'))
BEGIN
CREATE NONCLUSTERED INDEX [IX_Event_OrganizationId]
ON [dbo].[Event]([OrganizationId] ASC)
WHERE [OrganizationId] IS NOT NULL;
END
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.

⚠️ IMPORTANT: Building an index on Event without ONLINE = ON will block writes on a very large table during deploy.

Details and fix

dbo.Event is one of the highest-volume tables in the system. Creating a non-clustered index without WITH (ONLINE = ON) takes a schema-modification lock for the duration of the index build, which blocks all writes to the event stream while the migration runs. On Bitwarden Cloud this is the difference between a transparent deploy and an event-logging outage.

Azure SQL and SQL Server Enterprise both support online index builds; recommended:

CREATE NONCLUSTERED INDEX [IX_Event_OrganizationId]
    ON [dbo].[Event]([OrganizationId] ASC)
    WHERE [OrganizationId] IS NOT NULL
    WITH (ONLINE = ON, MAXDOP = 0);

Note: Self-hosted SQL Server Standard does not support ONLINE = ON. If self-hosted compatibility matters, gate the option behind an edition check or document the maintenance window expectation. The migration could also be split out of this PR so the index build runs separately from the table/sproc additions.

Comment on lines +17 to +24
public async Task CreateAsync(OrganizationEventCleanup cleanup)
{
using var connection = new SqlConnection(ConnectionString);
await connection.ExecuteAsync(
"[dbo].[OrganizationEventCleanup_Create]",
new { cleanup.Id, cleanup.OrganizationId, cleanup.QueuedAt },
commandType: CommandType.StoredProcedure);
}
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: CreateAsync does not call SetNewId(), diverging from the generic Repository<T,TId> convention.

Details and fix

The base Repository<T, TId>.CreateAsync calls obj.SetNewId() before the insert (see src/Infrastructure.Dapper/Repositories/Repository.cs:46-48). Because this repository inherits from BaseRepository directly, that behavior is lost — callers who construct an OrganizationEventCleanup without setting Id will insert Guid.Empty and the second such insert will collide on the PK.

public async Task CreateAsync(OrganizationEventCleanup cleanup)
{
    cleanup.SetNewId();
    using var connection = new SqlConnection(ConnectionString);
    await connection.ExecuteAsync(
        "[dbo].[OrganizationEventCleanup_Create]",
        new { cleanup.Id, cleanup.OrganizationId, cleanup.QueuedAt },
        commandType: CommandType.StoredProcedure);
}

Same applies for QueuedAt: setting it server-side (SYSUTCDATETIME() default on the column, or inside the sproc) would remove client-clock drift from the ordering used by ReadNextPending.

Copy link
Copy Markdown
Contributor

@mkincaid-bw mkincaid-bw left a comment

Choose a reason for hiding this comment

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

This PR creates a new table and modifies an existing table but there are no EF migrations to handle these changes for self-host/lite customers. Is that intentional?

Comment on lines +4 to +7
[QueuedAt] DATETIME2 (7) NOT NULL,
[StartedAt] DATETIME2 (7) NULL,
[LastProgressAt] DATETIME2 (7) NULL,
[CompletedAt] DATETIME2 (7) NULL,
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.

Datetime columns should end with Date, not At
https://contributing.bitwarden.com/contributing/code-style/sql/#column-definitions

Also, perhaps consider if the table should use our standard CreationDate column name for QueueDate and RevisionDate column name in lieu of LastProgressDate.

Suggested change
[QueuedAt] DATETIME2 (7) NOT NULL,
[StartedAt] DATETIME2 (7) NULL,
[LastProgressAt] DATETIME2 (7) NULL,
[CompletedAt] DATETIME2 (7) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NULL,
[StartDate] DATETIME2 (7) NULL,
[CompletedDate] DATETIME2 (7) NULL,

ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC) INCLUDE ([ServiceAccountId], [GrantedServiceAccountId]);

GO
CREATE NONCLUSTERED INDEX [IX_Event_OrganizationId]
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.

ℹ️ Just noting that Events for our cloud database are kept in Azure Table storage, not in the SQL database, so this index would only be applicable to self-host/lite customers.

@@ -0,0 +1,15 @@
CREATE PROCEDURE [dbo].[OrganizationEventCleanup_IncrementProgress]
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 stored procedure name does not follow our naming conventions. This should be named something like OrganizationEventCleanup_UpdateProgress.

[dbo].[OrganizationEventCleanup]
SET
[EventsDeletedCount] = [EventsDeletedCount] + @Delta,
[LastProgressAt] = SYSUTCDATETIME()
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.

When updating a value in the database, the value should be generated in code and passed in as parameters to the stored procedure.. We should not be using built-in SQL functions like SYSUTCDATETIME or GETUTCDATE

@@ -0,0 +1,14 @@
CREATE PROCEDURE [dbo].[OrganizationEventCleanup_MarkCompleted]
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 stored procedure name does not follow our naming conventions. This should be named something like OrganizationEventCleanup_UpdateCompleted.

Comment on lines +10 to +11
[CompletedAt] = SYSUTCDATETIME(),
[LastProgressAt] = SYSUTCDATETIME()
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.

See previous comment regarding SYSUTCDATETIME

@@ -0,0 +1,14 @@
CREATE PROCEDURE [dbo].[OrganizationEventCleanup_MarkStarted]
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 stored procedure name does not follow our naming conventions. This should be named something like OrganizationEventCleanup_UpdateStarted.

Comment on lines +10 to +11
[StartedAt] = COALESCE([StartedAt], SYSUTCDATETIME()),
[LastProgressAt] = SYSUTCDATETIME()
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.

See previous comments regarding SYSUTCDATETIME

@@ -0,0 +1,16 @@
CREATE PROCEDURE [dbo].[OrganizationEventCleanup_RecordError]
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 stored procedure name does not follow our naming conventions. This should be named something like OrganizationEventCleanup_UpdateError.

SET
[Attempts] = [Attempts] + 1,
[LastError] = @Message,
[LastProgressAt] = SYSUTCDATETIME()
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.

See previous comments regarding SYSUTCDATETIME

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.

3 participants