diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs index 4dee46268..ac5cdddab 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs @@ -57,7 +57,8 @@ namespace Squidex.Domain.Apps.Entities.Backup this.userResolver = userResolver; this.log = log; - state = new SimpleState(persistenceFactory, GetType(), appId); + // Enable locking for the parallel operations that might write stuff. + state = new SimpleState(persistenceFactory, GetType(), appId, true); } public async Task LoadAsync( diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs index 3b475b716..0aa4d0099 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs @@ -59,7 +59,8 @@ namespace Squidex.Domain.Apps.Entities.Backup this.userResolver = userResolver; this.log = log; - state = new SimpleState(persistenceFactory, GetType(), "Default"); + // Enable locking for the parallel operations that might write stuff. + state = new SimpleState(persistenceFactory, GetType(), "Default", true); } public async Task LoadAsync( diff --git a/backend/src/Squidex.Infrastructure/States/SimpleState.cs b/backend/src/Squidex.Infrastructure/States/SimpleState.cs index 9c8d69fb2..9781d852d 100644 --- a/backend/src/Squidex.Infrastructure/States/SimpleState.cs +++ b/backend/src/Squidex.Infrastructure/States/SimpleState.cs @@ -7,11 +7,14 @@ using NodaTime; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; +using Squidex.Log; namespace Squidex.Infrastructure.States { public class SimpleState where T : class, new() { + private readonly AsyncLock? lockObject; private readonly IPersistence persistence; private bool isLoaded; private Instant lastWrite; @@ -25,12 +28,12 @@ namespace Squidex.Infrastructure.States public IClock Clock { get; set; } = SystemClock.Instance; - public SimpleState(IPersistenceFactory persistenceFactory, Type ownerType, string id) - : this(persistenceFactory, ownerType, DomainId.Create(id)) + public SimpleState(IPersistenceFactory persistenceFactory, Type ownerType, string id, bool lockOperations = false) + : this(persistenceFactory, ownerType, DomainId.Create(id), lockOperations) { } - public SimpleState(IPersistenceFactory persistenceFactory, Type ownerType, DomainId id) + public SimpleState(IPersistenceFactory persistenceFactory, Type ownerType, DomainId id, bool lockOperations = false) { Guard.NotNull(persistenceFactory); @@ -38,53 +41,76 @@ namespace Squidex.Infrastructure.States { Value = state; }); + + if (lockOperations) + { + lockObject = new AsyncLock(); + } } public async Task LoadAsync( CancellationToken ct = default) { - await persistence.ReadAsync(ct: ct); - - isLoaded = true; + using (await LockAsync()) + { + await LoadInternalAsync(ct); + } } - public Task ClearAsync( + public async Task ClearAsync( CancellationToken ct = default) { - Value = new T(); + using (await LockAsync()) + { + // Reset state first, in case the deletion fails. + Value = new T(); - return persistence.DeleteAsync(ct); + await persistence.DeleteAsync(ct); + } } public async Task WriteAsync(int ifNotWrittenWithinMs, CancellationToken ct = default) { - var now = Clock.GetCurrentInstant(); - - if (ifNotWrittenWithinMs > 0 && now.Minus(lastWrite).TotalMilliseconds < ifNotWrittenWithinMs) + using (await LockAsync()) { - return; - } + // Calculate the timestamp once. + var now = Clock.GetCurrentInstant(); - await persistence.WriteSnapshotAsync(Value, ct); + if (ifNotWrittenWithinMs > 0 && now.Minus(lastWrite).TotalMilliseconds < ifNotWrittenWithinMs) + { + return; + } + + await persistence.WriteSnapshotAsync(Value, ct); - lastWrite = now; + // Only update the last write property if it is successful. + lastWrite = now; + } } public async Task WriteAsync( CancellationToken ct = default) { - await persistence.WriteSnapshotAsync(Value, ct); + using (await LockAsync()) + { + await persistence.WriteSnapshotAsync(Value, ct); - lastWrite = Clock.GetCurrentInstant(); + // Only update the last write property if it is successful. + lastWrite = Clock.GetCurrentInstant(); + } } public async Task WriteEventAsync(Envelope envelope, CancellationToken ct = default) { - await persistence.WriteEventAsync(envelope, ct); + using (await LockAsync()) + { + await persistence.WriteEventAsync(envelope, ct); - lastWrite = Clock.GetCurrentInstant(); + // Only update the last write property if it is successful. + lastWrite = Clock.GetCurrentInstant(); + } } public Task UpdateAsync(Func updater, int retries = 20, @@ -99,32 +125,55 @@ namespace Squidex.Infrastructure.States Guard.GreaterEquals(retries, 1); Guard.LessThan(retries, 100); - if (!isLoaded) - { - await LoadAsync(ct); - } - - for (var i = 0; i < retries; i++) + using (await LockAsync()) { - try + // Ensure that the state is loaded before we make the update. + if (!isLoaded) { - var (isChanged, result) = updater(Value); + await LoadInternalAsync(ct); + } - if (!isChanged) + for (var i = 0; i < retries; i++) + { + try { + var (isChanged, result) = updater(Value); + + // If nothing has been changed, we can avoid the call to the database. + if (!isChanged) + { + return result; + } + + await WriteAsync(ct); return result; } - - await WriteAsync(ct); - return result; - } - catch (InconsistentStateException) when (i < retries - 1) - { - await LoadAsync(ct); + catch (InconsistentStateException) when (i < retries - 1) + { + await LoadInternalAsync(ct); + } } + + return default!; + } + } + + private async Task LoadInternalAsync( + CancellationToken ct = default) + { + await persistence.ReadAsync(ct: ct); + + isLoaded = true; + } + + private Task LockAsync() + { + if (lockObject != null) + { + return lockObject.EnterAsync(); } - return default!; + return Task.FromResult(NoopDisposable.Instance); } } } diff --git a/frontend/src/app/theme/_common.scss b/frontend/src/app/theme/_common.scss index 250346200..2424b70c0 100644 --- a/frontend/src/app/theme/_common.scss +++ b/frontend/src/app/theme/_common.scss @@ -353,4 +353,57 @@ hr { hr { margin: .5rem 0; } +} + +// +// Animations +// +.spin { + animation: spin 3s infinite linear; +} + +.spin2 { + animation: spin2 1s infinite linear; +} + +i { + &.spin { + display: inline-block; + } + + &.spin2 { + display: inline-block; + } +} + +@keyframes spin2 { + 50% { + transform: rotate(180deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes spin { + 20% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(180deg); + } + + 70% { + transform: rotate(180deg); + } + + 80% { + transform: rotate(360deg); + } + + 100% { + transform: rotate(360deg); + } } \ No newline at end of file