diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index c9b67540c265..0a27af6c831f 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -17,6 +17,7 @@ using Umbraco.Cms.Core.DynamicRoot; using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Factories; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Core.Handlers; using Umbraco.Cms.Core.Hosting; @@ -344,6 +345,8 @@ private void AddCoreServices() factory.GetRequiredService>())); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Factories/IMachineInfoFactory.cs b/src/Umbraco.Core/Factories/IMachineInfoFactory.cs new file mode 100644 index 000000000000..f29a14c76d4e --- /dev/null +++ b/src/Umbraco.Core/Factories/IMachineInfoFactory.cs @@ -0,0 +1,13 @@ +namespace Umbraco.Cms.Core.Factories; + +/// +/// Fetches information of the host machine. +/// +public interface IMachineInfoFactory +{ + /// + /// Fetches the name of the Host Machine for identification. + /// + /// A name of the host machine. + public string GetMachineIdentifier(); +} diff --git a/src/Umbraco.Core/Factories/MachineInfoFactory.cs b/src/Umbraco.Core/Factories/MachineInfoFactory.cs new file mode 100644 index 000000000000..c4b5e00b1d48 --- /dev/null +++ b/src/Umbraco.Core/Factories/MachineInfoFactory.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Factories; + +internal sealed class MachineInfoFactory : IMachineInfoFactory +{ + + /// + public string GetMachineIdentifier() => Environment.MachineName; +} diff --git a/src/Umbraco.Core/Persistence/Repositories/ILastSyncedRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ILastSyncedRepository.cs new file mode 100644 index 000000000000..5dd9ab93bfc8 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/ILastSyncedRepository.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Cms.Core.Persistence.Repositories; + +/// +/// Handles saving and pruning of the LastSynced database table. +/// +public interface ILastSyncedRepository +{ + /// + /// Fetches the last synced internal ID from the database. + /// + /// The Internal ID from the database. + Task GetInternalIdAsync(); + + /// + /// Fetches the last synced external ID from the database. + /// + /// The External ID from the database. + Task GetExternalIdAsync(); + + /// + /// Saves the last synced Internal ID to the Database. + /// + /// The last synced internal ID. + Task SaveInternalIdAsync(int id); + + /// + /// Saves the last synced External ID to the Database. + /// + /// The last synced external ID. + Task SaveExternalIdAsync(int id); + + /// + /// Deletes entries older than the set parameter. This method also removes any entries where both + /// IDs are higher than the lowest synced CacheInstruction ID. + /// + /// Any date entries in the DB before this parameter, will be removed from the Database. + Task DeleteEntriesOlderThanAsync(DateTime pruneDate); +} diff --git a/src/Umbraco.Core/Sync/ILastSyncedManager.cs b/src/Umbraco.Core/Sync/ILastSyncedManager.cs new file mode 100644 index 000000000000..4909f4e477ad --- /dev/null +++ b/src/Umbraco.Core/Sync/ILastSyncedManager.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Cms.Core.Sync; + +/// +/// Handles saving and pruning of the LastSynced database table. +/// +public interface ILastSyncedManager +{ + /// + /// Fetches the last synced internal ID from the database. + /// + /// The Internal ID from the database. + Task GetLastSyncedInternalAsync(); + + /// + /// Fetches the last synced external ID from the database. + /// + /// The External ID from the database. + Task GetLastSyncedExternalAsync(); + + /// + /// Saves the last synced Internal ID to the Database. + /// + /// The last synced internal ID. + Task SaveLastSyncedInternalAsync(int id); + + /// + /// Saves the last synced External ID to the Database. + /// + /// The last synced external ID. + Task SaveLastSyncedExternalAsync(int id); + + /// + /// Deletes entries older than the set parameter. This method also removes any entries where both + /// IDs are higher than the lowest synced CacheInstruction ID. + /// + /// Any date entries in the DB before this parameter, will be removed from the Database. + Task DeleteOlderThanAsync(DateTime date); +} diff --git a/src/Umbraco.Core/Sync/LastSyncedManager.cs b/src/Umbraco.Core/Sync/LastSyncedManager.cs new file mode 100644 index 000000000000..bd98009a18ff --- /dev/null +++ b/src/Umbraco.Core/Sync/LastSyncedManager.cs @@ -0,0 +1,71 @@ +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Sync; + +/// +internal sealed class LastSyncedManager : ILastSyncedManager +{ + private readonly ILastSyncedRepository _lastSyncedRepository; + private readonly ICoreScopeProvider _coreScopeProvider; + + public LastSyncedManager(ILastSyncedRepository lastSyncedRepository, ICoreScopeProvider coreScopeProvider) + { + _lastSyncedRepository = lastSyncedRepository; + _coreScopeProvider = coreScopeProvider; + } + + /// + public async Task GetLastSyncedInternalAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + int? internalId = await _lastSyncedRepository.GetInternalIdAsync(); + scope.Complete(); + + return internalId; + } + + /// + public async Task GetLastSyncedExternalAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + int? externalId = await _lastSyncedRepository.GetExternalIdAsync(); + scope.Complete(); + + return externalId; + } + + /// + public async Task SaveLastSyncedInternalAsync(int id) + { + if (id < 0) + { + throw new ArgumentException("Invalid last synced id. Must be non-negative."); + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + await _lastSyncedRepository.SaveInternalIdAsync(id); + scope.Complete(); + } + + /// + public async Task SaveLastSyncedExternalAsync(int id) + { + if (id < 0) + { + throw new ArgumentException("Invalid last synced id. Must be non-negative."); + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + await _lastSyncedRepository.SaveExternalIdAsync(id); + scope.Complete(); + } + + /// + public async Task DeleteOlderThanAsync(DateTime date) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + await _lastSyncedRepository.DeleteEntriesOlderThanAsync(date); + scope.Complete(); + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs index fca52f1581b8..efac305a580b 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs @@ -2,7 +2,7 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; -using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; @@ -15,6 +15,7 @@ public class CacheInstructionsPruningJob : IRecurringBackgroundJob private readonly ICacheInstructionRepository _cacheInstructionRepository; private readonly ICoreScopeProvider _scopeProvider; private readonly TimeProvider _timeProvider; + private readonly ILastSyncedManager _lastSyncedManager; /// /// Initializes a new instance of the class. @@ -27,13 +28,15 @@ public CacheInstructionsPruningJob( IOptions globalSettings, ICacheInstructionRepository cacheInstructionRepository, ICoreScopeProvider scopeProvider, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILastSyncedManager lastSyncedManager) { _globalSettings = globalSettings; _cacheInstructionRepository = cacheInstructionRepository; _scopeProvider = scopeProvider; _timeProvider = timeProvider; Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenPruneOperations; + _lastSyncedManager = lastSyncedManager; } /// @@ -53,6 +56,7 @@ public Task RunJobAsync() using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { _cacheInstructionRepository.DeleteInstructionsOlderThan(pruneDate.DateTime); + _lastSyncedManager.DeleteOlderThanAsync(pruneDate.DateTime).GetAwaiter().GetResult(); scope.Complete(); } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 793f7f83ad45..1678ca74a9f9 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -84,6 +84,7 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_2_0/AddLastSyncedTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_2_0/AddLastSyncedTable.cs new file mode 100644 index 000000000000..117f2db2cda0 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_2_0/AddLastSyncedTable.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_2_0; + +public class AddLastSyncedTable : AsyncMigrationBase +{ + public AddLastSyncedTable(IMigrationContext context) + : base(context) + { + } + + protected override Task MigrateAsync() + { + if (TableExists(LastSyncedDto.TableName) is false) + { + Create.Table().Do(); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/LastSyncedDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/LastSyncedDto.cs new file mode 100644 index 000000000000..15c25a5ed3aa --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/LastSyncedDto.cs @@ -0,0 +1,29 @@ +using NPoco; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Constants = Umbraco.Cms.Core.Constants; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + + +[TableName(TableName)] +[PrimaryKey("machineId", AutoIncrement = false)] +[ExplicitColumns] +public class LastSyncedDto +{ + internal const string TableName = Constants.DatabaseSchema.Tables.LastSynced; + + [Column("machineId")] + [PrimaryKeyColumn(Name = "PK_lastSyncedMachineId", AutoIncrement = false, Clustered = true)] + public required string MachineId { get; set; } + + [Column("lastSyncedInternalId")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? LastSyncedInternalId { get; set; } + + [Column("lastSyncedExternalId")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? LastSyncedExternalId { get; set; } + + [Column("lastSyncedDate")] + public DateTime LastSyncedDate { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LastSyncedRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LastSyncedRepository.cs new file mode 100644 index 000000000000..4458c04e2b4f --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LastSyncedRepository.cs @@ -0,0 +1,103 @@ +using NPoco; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Factories; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +/// +public class LastSyncedRepository : RepositoryBase, ILastSyncedRepository +{ + private readonly IMachineInfoFactory _machineInfoFactory; + + public LastSyncedRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, IMachineInfoFactory machineInfoFactory) + : base(scopeAccessor, appCaches) + { + _machineInfoFactory = machineInfoFactory; + } + + + /// + public async Task GetInternalIdAsync() + { + string machineName = _machineInfoFactory.GetMachineIdentifier(); + + Sql sql = Database.SqlContext.Sql() + .Select(x => x.LastSyncedInternalId) + .From() + .Where(x => x.MachineId == machineName); + + return await Database.ExecuteScalarAsync(sql); + } + + /// + public async Task GetExternalIdAsync() + { + string machineName = _machineInfoFactory.GetMachineIdentifier(); + + Sql sql = Database.SqlContext.Sql() + .Select(x => x.LastSyncedExternalId) + .From() + .Where(x => x.MachineId == machineName); + + return await Database.ExecuteScalarAsync(sql); + } + + /// + public async Task SaveInternalIdAsync(int id) + { + LastSyncedDto dto = new LastSyncedDto() + { + MachineId = _machineInfoFactory.GetMachineIdentifier(), + LastSyncedInternalId = id, + LastSyncedDate = DateTime.Now, + }; + + await Database.InsertOrUpdateAsync( + dto, + "SET lastSyncedInternalId=@LastSyncedInternalId, lastSyncedDate=@LastSyncedDate WHERE machineId=@MachineId", + new + { + dto.LastSyncedInternalId, + dto.LastSyncedDate, + dto.MachineId, + }); + } + + /// + public async Task SaveExternalIdAsync(int id) + { + LastSyncedDto dto = new LastSyncedDto() + { + MachineId = _machineInfoFactory.GetMachineIdentifier(), + LastSyncedExternalId = id, + LastSyncedDate = DateTime.Now, + }; + + await Database.InsertOrUpdateAsync( + dto, + "SET lastSyncedExternalId=@LastSyncedExternalId, lastSyncedDate=@LastSyncedDate WHERE machineId=@MachineId", + new + { + dto.LastSyncedExternalId, + dto.LastSyncedDate, + dto.MachineId, + }); + } + + /// + public async Task DeleteEntriesOlderThanAsync(DateTime pruneDate) + { + var maxId = Database.ExecuteScalar($"SELECT MAX(Id) FROM umbracoCacheInstruction;"); + + Sql sql = + new Sql().Append( + @"DELETE FROM umbracoLastSynced WHERE lastSyncedDate < @pruneDate OR lastSyncedInternalId > @maxId AND lastSyncedExternalId > @maxId;", + new { pruneDate, maxId }); + + await Database.ExecuteAsync(sql); + } +} diff --git a/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs b/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs index 096318d34904..af383c26c385 100644 --- a/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs +++ b/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs @@ -1,8 +1,10 @@ using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Serialization; @@ -21,7 +23,7 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly IHostingEnvironment _hostingEnvironment; private readonly Lazy _initialized; - private readonly LastSyncedFileManager _lastSyncedFileManager; + private readonly ILastSyncedManager _lastSyncedManager; private readonly Lock _locko = new(); /* @@ -40,6 +42,7 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable /// /// Initializes a new instance of the class. /// + [Obsolete("Use the non-obsolete constructor. Scheduled for removal in V18.")] protected DatabaseServerMessenger( IMainDom mainDom, CacheRefresherCollection cacheRefreshers, @@ -51,33 +54,18 @@ protected DatabaseServerMessenger( IJsonSerializer jsonSerializer, LastSyncedFileManager lastSyncedFileManager, IOptionsMonitor globalSettings) - : base(distributedEnabled, jsonSerializer) + : this(mainDom, + cacheRefreshers, + logger, + distributedEnabled, + syncBootStateAccessor, + hostingEnvironment, + cacheInstructionService, + jsonSerializer, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService() + ) { - _cancellationToken = _cancellationTokenSource.Token; - _mainDom = mainDom; - _cacheRefreshers = cacheRefreshers; - _hostingEnvironment = hostingEnvironment; - Logger = logger; - _syncBootStateAccessor = syncBootStateAccessor; - CacheInstructionService = cacheInstructionService; - JsonSerializer = jsonSerializer; - _lastSyncedFileManager = lastSyncedFileManager; - GlobalSettings = globalSettings.CurrentValue; - _lastSync = DateTime.UtcNow; - _syncIdle = new ManualResetEvent(true); - - globalSettings.OnChange(x => GlobalSettings = x); - using (var process = Process.GetCurrentProcess()) - { - // See notes on _localIdentity - LocalIdentity = Environment.MachineName // eg DOMAIN\SERVER - + "/" + hostingEnvironment.ApplicationId // eg /LM/S3SVC/11/ROOT - + " [P" + process.Id // eg 1234 - + "/D" + AppDomain.CurrentDomain.Id // eg 22 - + "] " + Guid.NewGuid().ToString("N").ToUpper(); // make it truly unique - } - - _initialized = new Lazy(InitializeWithMainDom); } /// @@ -110,6 +98,50 @@ protected DatabaseServerMessenger( { } + /// + /// Initializes a new instance of the class. + /// + protected DatabaseServerMessenger( + IMainDom mainDom, + CacheRefresherCollection cacheRefreshers, + ILogger logger, + bool distributedEnabled, + ISyncBootStateAccessor syncBootStateAccessor, + IHostingEnvironment hostingEnvironment, + ICacheInstructionService cacheInstructionService, + IJsonSerializer jsonSerializer, + IOptionsMonitor globalSettings, + ILastSyncedManager lastSyncedManager + ) + : base(distributedEnabled, jsonSerializer) + { + _cancellationToken = _cancellationTokenSource.Token; + _mainDom = mainDom; + _cacheRefreshers = cacheRefreshers; + _hostingEnvironment = hostingEnvironment; + Logger = logger; + _syncBootStateAccessor = syncBootStateAccessor; + CacheInstructionService = cacheInstructionService; + JsonSerializer = jsonSerializer; + GlobalSettings = globalSettings.CurrentValue; + _lastSync = DateTime.UtcNow; + _syncIdle = new ManualResetEvent(true); + _lastSyncedManager = lastSyncedManager; + + globalSettings.OnChange(x => GlobalSettings = x); + using (var process = Process.GetCurrentProcess()) + { + // See notes on _localIdentity + LocalIdentity = Environment.MachineName // eg DOMAIN\SERVER + + "/" + hostingEnvironment.ApplicationId // eg /LM/S3SVC/11/ROOT + + " [P" + process.Id // eg 1234 + + "/D" + AppDomain.CurrentDomain.Id // eg 22 + + "] " + Guid.NewGuid().ToString("N").ToUpper(); // make it truly unique + } + + _initialized = new Lazy(InitializeWithMainDom); + } + public GlobalSettings GlobalSettings { get; private set; } protected ILogger Logger { get; } @@ -170,15 +202,16 @@ public override void Sync() try { + var lastSyncId = _lastSyncedManager.GetLastSyncedExternalAsync().GetAwaiter().GetResult() ?? -1; ProcessInstructionsResult result = CacheInstructionService.ProcessInstructions( _cacheRefreshers, _cancellationToken, LocalIdentity, - _lastSyncedFileManager.LastSyncedId); + lastSyncId); if (result.LastId > 0) { - _lastSyncedFileManager.SaveLastSyncedId(result.LastId); + _lastSyncedManager.SaveLastSyncedExternalAsync(result.LastId).GetAwaiter().GetResult(); } } finally @@ -297,15 +330,16 @@ private SyncBootState InitializeColdBootState() if (syncState == SyncBootState.ColdBoot) { + var lastSyncedId = _lastSyncedManager.GetLastSyncedExternalAsync().GetAwaiter().GetResult() ?? -1; // Get the last id in the db and store it. // Note: Do it BEFORE initializing otherwise some instructions might get lost // when doing it before. Some instructions might run twice but this is not an issue. var maxId = CacheInstructionService.GetMaxInstructionId(); // if there is a max currently, or if we've never synced - if (maxId > 0 || _lastSyncedFileManager.LastSyncedId < 0) + if (maxId > 0 || lastSyncedId < 0) { - _lastSyncedFileManager.SaveLastSyncedId(maxId); + _lastSyncedManager.SaveLastSyncedExternalAsync(maxId).GetAwaiter().GetResult(); } } diff --git a/src/Umbraco.Infrastructure/Sync/LastSyncedFileManager.cs b/src/Umbraco.Infrastructure/Sync/LastSyncedFileManager.cs index c56980932958..7e42f431ca89 100644 --- a/src/Umbraco.Infrastructure/Sync/LastSyncedFileManager.cs +++ b/src/Umbraco.Infrastructure/Sync/LastSyncedFileManager.cs @@ -4,6 +4,7 @@ namespace Umbraco.Cms.Infrastructure.Sync; +[Obsolete("Use the LastSyncedManager class instead. Scheduled for removal in V18")] public sealed class LastSyncedFileManager { private readonly IHostingEnvironment _hostingEnvironment; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Sync/LastSyncedManagerTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Sync/LastSyncedManagerTest.cs new file mode 100644 index 000000000000..270c402edd0b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Sync/LastSyncedManagerTest.cs @@ -0,0 +1,122 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Sync; + + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class LastSyncedManagerTest : UmbracoIntegrationTest +{ + private ILastSyncedManager manager => GetRequiredService(); + + [Test] + public async Task Last_Synced_Internal_Id_Is_Initially_Null() + { + var value = await manager.GetLastSyncedInternalAsync(); + Assert.IsNull(value); + } + + [Test] + public async Task Last_Synced_External_Id_Is_Initially_Null() + { + var value = await manager.GetLastSyncedExternalAsync(); + Assert.IsNull(value); + } + + [Test] + public async Task Last_Synced_Internal_Id_Cannot_Be_Negative() + { + Assert.Throws(() => manager.SaveLastSyncedInternalAsync(-1).GetAwaiter().GetResult()); + } + + [Test] + public async Task Last_Synced_External_Id_Cannot_Be_Negative() + { + Assert.Throws(() => manager.SaveLastSyncedExternalAsync(-1).GetAwaiter().GetResult()); + } + + [Test] + public async Task Save_Last_Synced_Internal_Id() + { + Random random = new Random(); + int testId = random.Next(); + await manager.SaveLastSyncedInternalAsync(testId); + int? lastSynced = await manager.GetLastSyncedInternalAsync(); + + Assert.AreEqual(testId, lastSynced); + } + + [Test] + public async Task Save_Last_Synced_External_Id() + { + Random random = new Random(); + int testId = random.Next(); + await manager.SaveLastSyncedExternalAsync(testId); + int? lastSynced = await manager.GetLastSyncedExternalAsync(); + + Assert.AreEqual(testId, lastSynced); + } + + [Test] + public async Task Delete_Old_Synced_External_Id() + { + Random random = new Random(); + int testId = random.Next(); + await manager.SaveLastSyncedExternalAsync(testId); + + // Make sure not to delete if not too old. + await manager.DeleteOlderThanAsync(DateTime.Now - TimeSpan.FromDays(1)); + int? lastSynced = await manager.GetLastSyncedExternalAsync(); + Assert.NotNull(lastSynced); + + // Make sure to delete if too old. + await manager.DeleteOlderThanAsync(DateTime.Now + TimeSpan.FromDays(1)); + lastSynced = await manager.GetLastSyncedExternalAsync(); + Assert.Null(lastSynced); + } + + [Test] + public async Task Delete_Old_Synced_Internal_Id() + { + Random random = new Random(); + int testId = random.Next(); + await manager.SaveLastSyncedInternalAsync(testId); + + // Make sure not to delete if not too old. + await manager.DeleteOlderThanAsync(DateTime.Now - TimeSpan.FromDays(1)); + int? lastSynced = await manager.GetLastSyncedInternalAsync(); + Assert.NotNull(lastSynced); + + // Make sure to delete if too old. + await manager.DeleteOlderThanAsync(DateTime.Now + TimeSpan.FromDays(1)); + lastSynced = await manager.GetLastSyncedInternalAsync(); + Assert.Null(lastSynced); + } + + [Test] + public async Task Delete_Out_Of_Sync_Id() + { + using (ScopeProvider.CreateScope()) + { + var repo = new CacheInstructionRepository((IScopeAccessor)ScopeProvider); + repo.Add(new CacheInstruction(0, DateTime.Now, "{}", "Test", 1)); + + Assert.IsTrue(repo.Exists(1)); + + await manager.SaveLastSyncedExternalAsync(2); + await manager.SaveLastSyncedInternalAsync(2); + + Assert.NotNull(await manager.GetLastSyncedExternalAsync()); + + await manager.DeleteOlderThanAsync(DateTime.Now); + + Assert.Null(await manager.GetLastSyncedExternalAsync()); + } + } +}