diff --git a/src/Core/Dirt/Entities/OrganizationEventCleanup.cs b/src/Core/Dirt/Entities/OrganizationEventCleanup.cs new file mode 100644 index 000000000000..0073cc2273db --- /dev/null +++ b/src/Core/Dirt/Entities/OrganizationEventCleanup.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Dirt.Entities; + +public class OrganizationEventCleanup : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime? RevisionDate { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? CompletedDate { get; set; } + public long EventsDeletedCount { get; set; } + public int FailureCount { get; set; } + public string? LastError { get; set; } + public void SetNewId() => Id = CoreHelpers.GenerateComb(); +} diff --git a/src/Core/Dirt/Repositories/IOrganizationEventCleanupRepository.cs b/src/Core/Dirt/Repositories/IOrganizationEventCleanupRepository.cs new file mode 100644 index 000000000000..2d69ca25099a --- /dev/null +++ b/src/Core/Dirt/Repositories/IOrganizationEventCleanupRepository.cs @@ -0,0 +1,12 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.Repositories; + +public interface IOrganizationEventCleanupRepository +{ + Task CreateAsync(OrganizationEventCleanup cleanup); + Task ClaimNextPendingAsync(); + Task UpdateProgressAsync(Guid id, long delta); + Task UpdateErrorAsync(Guid id, string message); + Task UpdateCompletedAsync(Guid id); +} diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index d31970869163..ed9192d1df5d 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -82,6 +82,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); if (selfHosted) diff --git a/src/Infrastructure.Dapper/Dirt/Repositories/OrganizationEventCleanupRepository.cs b/src/Infrastructure.Dapper/Dirt/Repositories/OrganizationEventCleanupRepository.cs new file mode 100644 index 000000000000..d6b715346757 --- /dev/null +++ b/src/Infrastructure.Dapper/Dirt/Repositories/OrganizationEventCleanupRepository.cs @@ -0,0 +1,66 @@ +using System.Data; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Dapper; +using Microsoft.Data.SqlClient; + +namespace Bit.Infrastructure.Dapper.Dirt.Repositories; + +public class OrganizationEventCleanupRepository : BaseRepository, IOrganizationEventCleanupRepository +{ + private const int LeaseDurationMinutes = 10; + private const int MaxFailureCount = 5; + + public OrganizationEventCleanupRepository(GlobalSettings globalSettings) + : base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + 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.CreationDate }, + commandType: CommandType.StoredProcedure); + } + + public async Task ClaimNextPendingAsync() + { + var now = DateTime.UtcNow; + using var connection = new SqlConnection(ConnectionString); + return await connection.QuerySingleOrDefaultAsync( + "[dbo].[OrganizationEventCleanup_ClaimNextPending]", + new { Now = now, StaleLeaseThreshold = now.AddMinutes(-LeaseDurationMinutes), MaxFailureCount }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateProgressAsync(Guid id, long delta) + { + using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + "[dbo].[OrganizationEventCleanup_UpdateProgress]", + new { Id = id, Delta = delta, Now = DateTime.UtcNow }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateErrorAsync(Guid id, string message) + { + using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + "[dbo].[OrganizationEventCleanup_UpdateError]", + new { Id = id, Message = message, Now = DateTime.UtcNow }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateCompletedAsync(Guid id) + { + using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + "[dbo].[OrganizationEventCleanup_UpdateCompleted]", + new { Id = id, Now = DateTime.UtcNow }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_ClaimNextPending.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_ClaimNextPending.sql new file mode 100644 index 000000000000..edc2bdefbb81 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_ClaimNextPending.sql @@ -0,0 +1,43 @@ +CREATE PROCEDURE [dbo].[OrganizationEventCleanup_ClaimNextPending] + @Now DATETIME2(7), + @StaleLeaseThreshold DATETIME2(7), + @MaxFailureCount INT +AS +BEGIN + SET NOCOUNT ON + + ;WITH [Pending] AS ( + SELECT TOP 1 + [Id], + [OrganizationId], + [CreationDate], + [RevisionDate], + [StartDate], + [CompletedDate], + [EventsDeletedCount], + [FailureCount], + [LastError] + FROM + [dbo].[OrganizationEventCleanup] WITH (UPDLOCK, READPAST) + WHERE + [CompletedDate] IS NULL + AND ([StartDate] IS NULL OR [RevisionDate] < @StaleLeaseThreshold) + AND [FailureCount] < @MaxFailureCount + ORDER BY + [CreationDate] ASC + ) + UPDATE [Pending] + SET + [StartDate] = COALESCE([StartDate], @Now), + [RevisionDate] = @Now + OUTPUT + inserted.[Id], + inserted.[OrganizationId], + inserted.[CreationDate], + inserted.[RevisionDate], + inserted.[StartDate], + inserted.[CompletedDate], + inserted.[EventsDeletedCount], + inserted.[FailureCount], + inserted.[LastError] +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_Create.sql new file mode 100644 index 000000000000..9d1b726c481b --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_Create.sql @@ -0,0 +1,21 @@ +CREATE PROCEDURE [dbo].[OrganizationEventCleanup_Create] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationEventCleanup] + ( + [Id], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @CreationDate + ) +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateCompleted.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateCompleted.sql new file mode 100644 index 000000000000..b38d4be474c0 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateCompleted.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[OrganizationEventCleanup_UpdateCompleted] + @Id UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationEventCleanup] + SET + [CompletedDate] = @Now, + [RevisionDate] = @Now + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateError.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateError.sql new file mode 100644 index 000000000000..843946ec8062 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateError.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[OrganizationEventCleanup_UpdateError] + @Id UNIQUEIDENTIFIER, + @Message NVARCHAR(MAX), + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationEventCleanup] + SET + [FailureCount] = [FailureCount] + 1, + [LastError] = @Message, + [RevisionDate] = @Now + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateProgress.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateProgress.sql new file mode 100644 index 000000000000..3c88a6c1ceb7 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationEventCleanup_UpdateProgress.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationEventCleanup_UpdateProgress] + @Id UNIQUEIDENTIFIER, + @Delta BIGINT, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationEventCleanup] + SET + [EventsDeletedCount] = [EventsDeletedCount] + @Delta, + [RevisionDate] = @Now + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Dirt/Tables/OrganizationEventCleanup.sql b/src/Sql/dbo/Dirt/Tables/OrganizationEventCleanup.sql new file mode 100644 index 000000000000..27582da13235 --- /dev/null +++ b/src/Sql/dbo/Dirt/Tables/OrganizationEventCleanup.sql @@ -0,0 +1,17 @@ +CREATE TABLE [dbo].[OrganizationEventCleanup] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NULL, + [StartDate] DATETIME2 (7) NULL, + [CompletedDate] DATETIME2 (7) NULL, + [EventsDeletedCount] BIGINT NOT NULL CONSTRAINT [DF_OrganizationEventCleanup_EventsDeletedCount] DEFAULT (0), + [FailureCount] INT NOT NULL CONSTRAINT [DF_OrganizationEventCleanup_FailureCount] DEFAULT (0), + [LastError] NVARCHAR(MAX) NULL, + CONSTRAINT [PK_OrganizationEventCleanup] PRIMARY KEY CLUSTERED ([Id] ASC) +); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationEventCleanup_CompletedDate_CreationDate] + ON [dbo].[OrganizationEventCleanup]([CompletedDate] ASC, [CreationDate] ASC); +GO diff --git a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs index 3d2670af4467..fc6d29dd5485 100644 --- a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs +++ b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs @@ -29,6 +29,7 @@ private static IConfiguration GetConfiguration() public bool SelfHosted { get; set; } public bool UseFakeTimeProvider { get; set; } + public SupportedDatabaseProviders[] OnlyOn { get; set; } = []; public override ValueTask> GetData(MethodInfo testMethod, DisposalTracker disposalTracker) { @@ -48,6 +49,16 @@ public override ValueTask> GetData(MethodInf { unconfiguredDatabases.Remove(database.Type); + if (OnlyOn.Length > 0 && !OnlyOn.Contains(database.Type)) + { + var theory = new TheoryDataRow() + .WithSkip($"Provider {database.Type} not in OnlyOn") + .WithTrait("Database", database.Type.ToString()); + theory.Label = database.Type.ToString(); + theories.Add(theory); + continue; + } + if (!database.Enabled) { var theory = new TheoryDataRow() @@ -85,6 +96,11 @@ public override ValueTask> GetData(MethodInf foreach (var unconfiguredDatabase in unconfiguredDatabases) { + if (OnlyOn.Length > 0 && !OnlyOn.Contains(unconfiguredDatabase)) + { + continue; + } + var theory = new TheoryDataRow() .WithSkip("Unconfigured") .WithTrait("Database", unconfiguredDatabase.ToString()); diff --git a/test/Infrastructure.IntegrationTest/Dirt/Repositories/OrganizationEventCleanupRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Dirt/Repositories/OrganizationEventCleanupRepositoryTests.cs new file mode 100644 index 000000000000..a2244f0b92c1 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Dirt/Repositories/OrganizationEventCleanupRepositoryTests.cs @@ -0,0 +1,150 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Enums; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Dirt.Repositories; + +public class OrganizationEventCleanupRepositoryTests +{ + [Theory, DatabaseData(OnlyOn = [SupportedDatabaseProviders.SqlServer])] + public async Task ClaimNextPendingAsync_PendingRow_ReturnsRowWithLeaseSet( + IOrganizationEventCleanupRepository sut) + { + var cleanup = new OrganizationEventCleanup + { + OrganizationId = Guid.NewGuid(), + CreationDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + await sut.CreateAsync(cleanup); + + var claimed = await sut.ClaimNextPendingAsync(); + + Assert.NotNull(claimed); + Assert.Equal(cleanup.Id, claimed.Id); + Assert.NotNull(claimed.StartDate); + Assert.NotNull(claimed.RevisionDate); + Assert.Null(claimed.CompletedDate); + } + + [Theory, DatabaseData(OnlyOn = [SupportedDatabaseProviders.SqlServer])] + public async Task UpdateProgressAsync_And_UpdateCompletedAsync_UpdatesRow( + IOrganizationEventCleanupRepository sut, Database database) + { + var cleanup = new OrganizationEventCleanup { OrganizationId = Guid.NewGuid() }; + await sut.CreateAsync(cleanup); + + await sut.UpdateProgressAsync(cleanup.Id, 42); + var afterProgress = await QueryRowAsync(database.ConnectionString, cleanup.Id); + Assert.Equal(42, afterProgress.EventsDeletedCount); + + await sut.UpdateCompletedAsync(cleanup.Id); + var afterCompletion = await QueryRowAsync(database.ConnectionString, cleanup.Id); + Assert.NotNull(afterCompletion.CompletedDate); + } + + [Theory, DatabaseData(OnlyOn = [SupportedDatabaseProviders.SqlServer])] + public async Task ClaimNextPendingAsync_ConcurrentCalls_RowClaimedOnlyOnce( + IOrganizationEventCleanupRepository sut) + { + var cleanup = new OrganizationEventCleanup + { + OrganizationId = Guid.NewGuid(), + CreationDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + await sut.CreateAsync(cleanup); + + var results = await Task.WhenAll( + sut.ClaimNextPendingAsync(), + sut.ClaimNextPendingAsync()); + + Assert.Equal(1, results.Count(r => r?.Id == cleanup.Id)); + } + + [Theory, DatabaseData(OnlyOn = [SupportedDatabaseProviders.SqlServer])] + public async Task ClaimNextPendingAsync_StaleRevisionDate_RowIsReclaimable( + IOrganizationEventCleanupRepository sut, Database database) + { + var cleanup = new OrganizationEventCleanup + { + OrganizationId = Guid.NewGuid(), + CreationDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + await sut.CreateAsync(cleanup); + + var firstClaim = await sut.ClaimNextPendingAsync(); + Assert.NotNull(firstClaim); + Assert.Equal(cleanup.Id, firstClaim.Id); + + await BackdateRevisionDateAsync(database.ConnectionString, cleanup.Id, minutes: -15); + + var secondClaim = await sut.ClaimNextPendingAsync(); + Assert.NotNull(secondClaim); + Assert.Equal(cleanup.Id, secondClaim.Id); + } + + [Theory, DatabaseData(OnlyOn = [SupportedDatabaseProviders.SqlServer])] + public async Task ClaimNextPendingAsync_FailureCountAtMax_RowNotClaimed( + IOrganizationEventCleanupRepository sut) + { + var cleanup = new OrganizationEventCleanup + { + OrganizationId = Guid.NewGuid(), + CreationDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + await sut.CreateAsync(cleanup); + + for (var i = 0; i < 5; i++) + { + await sut.UpdateErrorAsync(cleanup.Id, $"Error {i + 1}"); + } + + var claimed = await sut.ClaimNextPendingAsync(); + + Assert.True(claimed == null || claimed.Id != cleanup.Id); + } + + private static async Task QueryRowAsync(string connectionString, Guid id) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = """ + SELECT [Id], [OrganizationId], [CreationDate], [RevisionDate], [StartDate], + [CompletedDate], [EventsDeletedCount], [FailureCount], [LastError] + FROM [dbo].[OrganizationEventCleanup] + WHERE [Id] = @Id + """; + cmd.Parameters.AddWithValue("@Id", id); + await using var reader = await cmd.ExecuteReaderAsync(); + await reader.ReadAsync(); + return new OrganizationEventCleanup + { + Id = reader.GetGuid(0), + OrganizationId = reader.GetGuid(1), + CreationDate = reader.GetDateTime(2), + RevisionDate = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + StartDate = reader.IsDBNull(4) ? null : reader.GetDateTime(4), + CompletedDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + EventsDeletedCount = reader.GetInt64(6), + FailureCount = reader.GetInt32(7), + LastError = reader.IsDBNull(8) ? null : reader.GetString(8), + }; + } + + private static async Task BackdateRevisionDateAsync(string connectionString, Guid id, int minutes) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = """ + UPDATE [dbo].[OrganizationEventCleanup] + SET [RevisionDate] = DATEADD(MINUTE, @Minutes, SYSUTCDATETIME()) + WHERE [Id] = @Id + """; + cmd.Parameters.AddWithValue("@Id", id); + cmd.Parameters.AddWithValue("@Minutes", minutes); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/util/Migrator/DbScripts/2026-05-20_00_AddOrganizationEventCleanup.sql b/util/Migrator/DbScripts/2026-05-20_00_AddOrganizationEventCleanup.sql new file mode 100644 index 000000000000..5f6f8a2309a7 --- /dev/null +++ b/util/Migrator/DbScripts/2026-05-20_00_AddOrganizationEventCleanup.sql @@ -0,0 +1,155 @@ +-- OrganizationEventCleanup + +-- Table +IF OBJECT_ID('[dbo].[OrganizationEventCleanup]') IS NULL +BEGIN + CREATE TABLE [dbo].[OrganizationEventCleanup] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NULL, + [StartDate] DATETIME2 (7) NULL, + [CompletedDate] DATETIME2 (7) NULL, + [EventsDeletedCount] BIGINT NOT NULL CONSTRAINT [DF_OrganizationEventCleanup_EventsDeletedCount] DEFAULT (0), + [FailureCount] INT NOT NULL CONSTRAINT [DF_OrganizationEventCleanup_FailureCount] DEFAULT (0), + [LastError] NVARCHAR(MAX) NULL, + CONSTRAINT [PK_OrganizationEventCleanup] PRIMARY KEY CLUSTERED ([Id] ASC) + ); +END +GO + +-- Index +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationEventCleanup_CompletedDate_CreationDate') +BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationEventCleanup_CompletedDate_CreationDate] + ON [dbo].[OrganizationEventCleanup]([CompletedDate] ASC, [CreationDate] ASC); +END +GO + + +-- Stored Procedures: Create +CREATE OR ALTER PROCEDURE [dbo].[OrganizationEventCleanup_Create] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationEventCleanup] + ( + [Id], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @OrganizationId, + @CreationDate + ) +END +GO + +-- Stored Procedures: ClaimNextPending +CREATE OR ALTER PROCEDURE [dbo].[OrganizationEventCleanup_ClaimNextPending] + @Now DATETIME2(7), + @StaleLeaseThreshold DATETIME2(7), + @MaxFailureCount INT +AS +BEGIN + SET NOCOUNT ON + + ;WITH [Pending] AS ( + SELECT TOP 1 + [Id], + [OrganizationId], + [CreationDate], + [RevisionDate], + [StartDate], + [CompletedDate], + [EventsDeletedCount], + [FailureCount], + [LastError] + FROM + [dbo].[OrganizationEventCleanup] WITH (UPDLOCK, READPAST) + WHERE + [CompletedDate] IS NULL + AND ([StartDate] IS NULL OR [RevisionDate] < @StaleLeaseThreshold) + AND [FailureCount] < @MaxFailureCount + ORDER BY + [CreationDate] ASC + ) + UPDATE [Pending] + SET + [StartDate] = COALESCE([StartDate], @Now), + [RevisionDate] = @Now + OUTPUT + inserted.[Id], + inserted.[OrganizationId], + inserted.[CreationDate], + inserted.[RevisionDate], + inserted.[StartDate], + inserted.[CompletedDate], + inserted.[EventsDeletedCount], + inserted.[FailureCount], + inserted.[LastError] +END +GO + +-- Stored Procedures: UpdateProgress +CREATE OR ALTER PROCEDURE [dbo].[OrganizationEventCleanup_UpdateProgress] + @Id UNIQUEIDENTIFIER, + @Delta BIGINT, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationEventCleanup] + SET + [EventsDeletedCount] = [EventsDeletedCount] + @Delta, + [RevisionDate] = @Now + WHERE + [Id] = @Id +END +GO + +-- Stored Procedures: UpdateError +CREATE OR ALTER PROCEDURE [dbo].[OrganizationEventCleanup_UpdateError] + @Id UNIQUEIDENTIFIER, + @Message NVARCHAR(MAX), + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationEventCleanup] + SET + [FailureCount] = [FailureCount] + 1, + [LastError] = @Message, + [RevisionDate] = @Now + WHERE + [Id] = @Id +END +GO + +-- Stored Procedures: UpdateCompleted +CREATE OR ALTER PROCEDURE [dbo].[OrganizationEventCleanup_UpdateCompleted] + @Id UNIQUEIDENTIFIER, + @Now DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationEventCleanup] + SET + [CompletedDate] = @Now, + [RevisionDate] = @Now + WHERE + [Id] = @Id +END +GO