mirror of https://github.com/Squidex/squidex.git
Browse Source
* Temp * Temp. * Rename messages. * Some progress. * Some progress. * Some fixes in registration of dependencies. * Infrastructure tests * More tests. * More tests. * Naming fixes. * Compile fix * More fixes. * Tests green :) * More tests and distributed cache. * Compile and bug fixes. * Fix recreation. * Fix tests and better cancellation token flow. * Flow the cancellation token. * Compile fix. * Build fix. * Fixes and better tests. * Fix threading. * File was not saved. * More cancellation tokens. * Fix method name. * Fix tests. * Update yml. * Better check. * Increase time for text indexer.pull/898/head
committed by
GitHub
467 changed files with 7220 additions and 11061 deletions
@ -1,30 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans; |
|||
using Squidex.Infrastructure.EventSourcing.Grains; |
|||
using Squidex.Infrastructure.Migrations; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Migrations.Migrations |
|||
{ |
|||
public sealed class StartEventConsumers : IMigration |
|||
{ |
|||
private readonly IEventConsumerManagerGrain eventConsumerManager; |
|||
|
|||
public StartEventConsumers(IGrainFactory grainFactory) |
|||
{ |
|||
eventConsumerManager = grainFactory.GetGrain<IEventConsumerManagerGrain>(SingleGrain.Id); |
|||
} |
|||
|
|||
public Task UpdateAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
return eventConsumerManager.StartAllAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,30 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans; |
|||
using Squidex.Infrastructure.EventSourcing.Grains; |
|||
using Squidex.Infrastructure.Migrations; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Migrations.Migrations |
|||
{ |
|||
public sealed class StopEventConsumers : IMigration |
|||
{ |
|||
private readonly IEventConsumerManagerGrain eventConsumerManager; |
|||
|
|||
public StopEventConsumers(IGrainFactory grainFactory) |
|||
{ |
|||
eventConsumerManager = grainFactory.GetGrain<IEventConsumerManagerGrain>(SingleGrain.Id); |
|||
} |
|||
|
|||
public Task UpdateAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
return eventConsumerManager.StopAllAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,120 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans.Core; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json.Objects; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps |
|||
{ |
|||
public sealed class AppUISettingsGrain : GrainBase, IAppUISettingsGrain |
|||
{ |
|||
private readonly IGrainState<State> state; |
|||
|
|||
[CollectionName("UISettings")] |
|||
public sealed class State |
|||
{ |
|||
public JsonObject Settings { get; set; } = new JsonObject(); |
|||
} |
|||
|
|||
public AppUISettingsGrain(IGrainIdentity identity, IGrainState<State> state) |
|||
: base(identity) |
|||
{ |
|||
this.state = state; |
|||
} |
|||
|
|||
public Task<JsonObject> GetAsync() |
|||
{ |
|||
return Task.FromResult(state.Value.Settings); |
|||
} |
|||
|
|||
public Task ClearAsync() |
|||
{ |
|||
TryDeactivateOnIdle(); |
|||
|
|||
return state.ClearAsync(); |
|||
} |
|||
|
|||
public Task SetAsync(JsonObject settings) |
|||
{ |
|||
state.Value.Settings = settings; |
|||
|
|||
return state.WriteAsync(); |
|||
} |
|||
|
|||
public Task SetAsync(string path, JsonValue value) |
|||
{ |
|||
var container = GetContainer(path, true, out var key); |
|||
|
|||
if (container == null) |
|||
{ |
|||
ThrowHelper.InvalidOperationException("Path does not lead to an object."); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
container[key] = value; |
|||
|
|||
return state.WriteAsync(); |
|||
} |
|||
|
|||
public async Task RemoveAsync(string path) |
|||
{ |
|||
var container = GetContainer(path, false, out var key); |
|||
|
|||
if (container?.ContainsKey(key) == true) |
|||
{ |
|||
container.Remove(key); |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
} |
|||
|
|||
private JsonObject? GetContainer(string path, bool add, out string key) |
|||
{ |
|||
Guard.NotNullOrEmpty(path); |
|||
|
|||
var segments = path.Split('.'); |
|||
|
|||
key = segments[^1]; |
|||
|
|||
var current = state.Value.Settings; |
|||
|
|||
if (segments.Length > 1) |
|||
{ |
|||
foreach (var segment in segments.Take(segments.Length - 1)) |
|||
{ |
|||
if (!current.TryGetValue(segment, out var found)) |
|||
{ |
|||
if (add) |
|||
{ |
|||
found = new JsonObject(); |
|||
|
|||
current[segment] = found; |
|||
} |
|||
else |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
if (found.Value is JsonObject o) |
|||
{ |
|||
current = o; |
|||
} |
|||
else |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return current; |
|||
} |
|||
} |
|||
} |
|||
@ -1,37 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Diagnostics.HealthChecks; |
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Entities.Apps.Indexes; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Diagnostics |
|||
{ |
|||
public sealed class OrleansAppsHealthCheck : IHealthCheck |
|||
{ |
|||
private readonly IGrainFactory grainFactory; |
|||
|
|||
public OrleansAppsHealthCheck(IGrainFactory grainFactory) |
|||
{ |
|||
this.grainFactory = grainFactory; |
|||
} |
|||
|
|||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
await GetGrain().GetAppIdsAsync(new[] { "test" }); |
|||
|
|||
return HealthCheckResult.Healthy("Orleans must establish communication."); |
|||
} |
|||
|
|||
private IAppsCacheGrain GetGrain() |
|||
{ |
|||
return grainFactory.GetGrain<IAppsCacheGrain>(SingleGrain.Id); |
|||
} |
|||
} |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans.Core; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject |
|||
{ |
|||
public sealed class AppDomainObjectGrain : DomainObjectGrain<AppDomainObject, AppDomainObject.State>, IAppGrain |
|||
{ |
|||
public AppDomainObjectGrain(IGrainIdentity identity, IDomainObjectFactory factory) |
|||
: base(identity, factory) |
|||
{ |
|||
} |
|||
|
|||
public async Task<IAppEntity> GetStateAsync() |
|||
{ |
|||
await DomainObject.EnsureLoadedAsync(); |
|||
|
|||
return Snapshot; |
|||
} |
|||
} |
|||
} |
|||
@ -1,16 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject |
|||
{ |
|||
public interface IAppGrain : IDomainObjectGrain |
|||
{ |
|||
Task<IAppEntity> GetStateAsync(); |
|||
} |
|||
} |
|||
@ -1,108 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans.Concurrency; |
|||
using Orleans.Core; |
|||
using Squidex.Domain.Apps.Entities.Apps.Repositories; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Orleans.Indexes; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Indexes |
|||
{ |
|||
[Reentrant] |
|||
public sealed class AppsCacheGrain : UniqueNameGrain<DomainId>, IAppsCacheGrain |
|||
{ |
|||
private readonly IAppRepository appRepository; |
|||
private readonly Dictionary<string, DomainId> appIds = new Dictionary<string, DomainId>(); |
|||
|
|||
public AppsCacheGrain(IGrainIdentity identity, IAppRepository appRepository) |
|||
: base(identity) |
|||
{ |
|||
this.appRepository = appRepository; |
|||
} |
|||
|
|||
public override async Task<string?> ReserveAsync(DomainId id, string name) |
|||
{ |
|||
var token = await base.ReserveAsync(id, name); |
|||
|
|||
if (token == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var ids = await GetAppIdsAsync(new[] { name }); |
|||
|
|||
if (ids.Any()) |
|||
{ |
|||
await RemoveReservationAsync(token); |
|||
return null; |
|||
} |
|||
|
|||
return token; |
|||
} |
|||
|
|||
public async Task<IReadOnlyCollection<DomainId>> GetAppIdsAsync(string[] names) |
|||
{ |
|||
var result = new List<DomainId>(); |
|||
|
|||
List<string>? pendingNames = null; |
|||
|
|||
foreach (var name in names) |
|||
{ |
|||
if (!appIds.TryGetValue(name, out var cachedId)) |
|||
{ |
|||
pendingNames ??= new List<string>(); |
|||
pendingNames.Add(name); |
|||
} |
|||
else if (cachedId != DomainId.Empty) |
|||
{ |
|||
result.Add(cachedId); |
|||
} |
|||
} |
|||
|
|||
if (pendingNames != null) |
|||
{ |
|||
var foundIds = await appRepository.QueryIdsAsync(pendingNames); |
|||
|
|||
foreach (var name in pendingNames) |
|||
{ |
|||
if (foundIds.TryGetValue(name, out var id)) |
|||
{ |
|||
appIds[name] = id; |
|||
|
|||
result.Add(id); |
|||
} |
|||
else |
|||
{ |
|||
appIds[name] = default; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public Task AddAsync(DomainId id, string name) |
|||
{ |
|||
appIds[name] = id; |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task RemoveAsync(DomainId id) |
|||
{ |
|||
var name = appIds.FirstOrDefault(x => x.Value == id).Key; |
|||
|
|||
if (name != null) |
|||
{ |
|||
appIds.Remove(name); |
|||
} |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
} |
|||
} |
|||
@ -1,21 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Orleans.Indexes; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Indexes |
|||
{ |
|||
public interface IAppsCacheGrain : IUniqueNameGrain<DomainId> |
|||
{ |
|||
Task<IReadOnlyCollection<DomainId>> GetAppIdsAsync(string[] names); |
|||
|
|||
Task AddAsync(DomainId id, string name); |
|||
|
|||
Task RemoveAsync(DomainId id); |
|||
} |
|||
} |
|||
@ -1,18 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans; |
|||
using Orleans.Concurrency; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Plans |
|||
{ |
|||
public interface IUsageNotifierGrain : IGrainWithStringKey |
|||
{ |
|||
[OneWay] |
|||
Task NotifyAsync(UsageNotification notification); |
|||
} |
|||
} |
|||
@ -1,40 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans.Core; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject |
|||
{ |
|||
public sealed class AssetDomainObjectGrain : DomainObjectGrain<AssetDomainObject, AssetDomainObject.State>, IAssetGrain |
|||
{ |
|||
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); |
|||
|
|||
public AssetDomainObjectGrain(IGrainIdentity identity, IDomainObjectFactory factory, |
|||
IActivationLimit limit) |
|||
: base(identity, factory) |
|||
{ |
|||
limit?.SetLimit(5000, Lifetime); |
|||
} |
|||
|
|||
public override Task OnActivateAsync() |
|||
{ |
|||
TryDelayDeactivation(Lifetime); |
|||
|
|||
return base.OnActivateAsync(); |
|||
} |
|||
|
|||
public async Task<IAssetEntity> GetStateAsync(long version = EtagVersion.Any) |
|||
{ |
|||
await DomainObject.EnsureLoadedAsync(); |
|||
|
|||
return await DomainObject.GetSnapshotAsync(version); |
|||
} |
|||
} |
|||
} |
|||
@ -1,39 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans.Core; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject |
|||
{ |
|||
public sealed class AssetFolderDomainObjectGrain : DomainObjectGrain<AssetFolderDomainObject, AssetFolderDomainObject.State>, IAssetFolderGrain |
|||
{ |
|||
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); |
|||
|
|||
public AssetFolderDomainObjectGrain(IGrainIdentity grainIdentity, IDomainObjectFactory factory, |
|||
IActivationLimit limit) |
|||
: base(grainIdentity, factory) |
|||
{ |
|||
limit?.SetLimit(5000, Lifetime); |
|||
} |
|||
|
|||
public override Task OnActivateAsync() |
|||
{ |
|||
TryDelayDeactivation(Lifetime); |
|||
|
|||
return base.OnActivateAsync(); |
|||
} |
|||
|
|||
public async Task<IAssetFolderEntity> GetStateAsync() |
|||
{ |
|||
await DomainObject.EnsureLoadedAsync(); |
|||
|
|||
return Snapshot; |
|||
} |
|||
} |
|||
} |
|||
@ -1,17 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject |
|||
{ |
|||
public interface IAssetGrain : IDomainObjectGrain |
|||
{ |
|||
Task<IAssetEntity> GetStateAsync(long version = EtagVersion.Any); |
|||
} |
|||
} |
|||
@ -1,285 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Text.RegularExpressions; |
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Orleans.Concurrency; |
|||
using Orleans.Core; |
|||
using Squidex.Domain.Apps.Entities.Backup.State; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.Tasks; |
|||
using Squidex.Infrastructure.Translations; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Backup |
|||
{ |
|||
[Reentrant] |
|||
public sealed class BackupGrain : GrainBase, IBackupGrain |
|||
{ |
|||
private const int MaxBackups = 10; |
|||
private static readonly Duration UpdateDuration = Duration.FromSeconds(1); |
|||
private readonly IBackupArchiveLocation backupArchiveLocation; |
|||
private readonly IBackupArchiveStore backupArchiveStore; |
|||
private readonly IBackupHandlerFactory backupHandlers; |
|||
private readonly IClock clock; |
|||
private readonly IEventFormatter eventFormatter; |
|||
private readonly IEventStore eventStore; |
|||
private readonly IGrainState<BackupState> state; |
|||
private readonly IUserResolver userResolver; |
|||
private readonly ILogger<BackupGrain> log; |
|||
private CancellationTokenSource? currentJobToken; |
|||
private BackupJob? currentJob; |
|||
|
|||
public BackupGrain(IGrainIdentity identity, |
|||
IBackupArchiveLocation backupArchiveLocation, |
|||
IBackupArchiveStore backupArchiveStore, |
|||
IBackupHandlerFactory backupHandlers, |
|||
IClock clock, |
|||
IEventFormatter eventFormatter, |
|||
IEventStore eventStore, |
|||
IGrainState<BackupState> state, |
|||
IUserResolver userResolver, |
|||
ILogger<BackupGrain> log) |
|||
: base(identity) |
|||
{ |
|||
this.backupArchiveLocation = backupArchiveLocation; |
|||
this.backupArchiveStore = backupArchiveStore; |
|||
this.backupHandlers = backupHandlers; |
|||
this.clock = clock; |
|||
this.eventFormatter = eventFormatter; |
|||
this.eventStore = eventStore; |
|||
this.state = state; |
|||
this.userResolver = userResolver; |
|||
|
|||
this.log = log; |
|||
} |
|||
|
|||
public override Task OnActivateAsync() |
|||
{ |
|||
RecoverAfterRestartAsync().Forget(); |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
private async Task RecoverAfterRestartAsync() |
|||
{ |
|||
state.Value.Jobs.RemoveAll(x => x.Stopped == null); |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
|
|||
public async Task ClearAsync() |
|||
{ |
|||
foreach (var backup in state.Value.Jobs) |
|||
{ |
|||
await backupArchiveStore.DeleteAsync(backup.Id, default); |
|||
} |
|||
|
|||
TryDeactivateOnIdle(); |
|||
|
|||
await state.ClearAsync(); |
|||
} |
|||
|
|||
public async Task BackupAsync(RefToken actor) |
|||
{ |
|||
if (currentJobToken != null) |
|||
{ |
|||
throw new DomainException(T.Get("backups.alreadyRunning")); |
|||
} |
|||
|
|||
if (state.Value.Jobs.Count >= MaxBackups) |
|||
{ |
|||
throw new DomainException(T.Get("backups.maxReached", new { max = MaxBackups })); |
|||
} |
|||
|
|||
var job = new BackupJob |
|||
{ |
|||
Id = DomainId.NewGuid(), |
|||
Started = clock.GetCurrentInstant(), |
|||
Status = JobStatus.Started |
|||
}; |
|||
|
|||
currentJobToken = new CancellationTokenSource(); |
|||
currentJob = job; |
|||
|
|||
state.Value.Jobs.Insert(0, job); |
|||
|
|||
await state.WriteAsync(); |
|||
|
|||
#pragma warning disable MA0042 // Do not use blocking calls in an async method
|
|||
Process(job, actor, currentJobToken.Token); |
|||
#pragma warning restore MA0042 // Do not use blocking calls in an async method
|
|||
} |
|||
|
|||
private void Process(BackupJob job, RefToken actor, |
|||
CancellationToken ct) |
|||
{ |
|||
ProcessAsync(job, actor, ct).Forget(); |
|||
} |
|||
|
|||
private async Task ProcessAsync(BackupJob job, RefToken actor, |
|||
CancellationToken ct) |
|||
{ |
|||
var handlers = backupHandlers.CreateMany(); |
|||
|
|||
var lastTimestamp = job.Started; |
|||
|
|||
try |
|||
{ |
|||
await using (var stream = backupArchiveLocation.OpenStream(job.Id)) |
|||
{ |
|||
using (var writer = await backupArchiveLocation.OpenWriterAsync(stream)) |
|||
{ |
|||
await writer.WriteVersionAsync(); |
|||
|
|||
var userMapping = new UserMapping(actor); |
|||
|
|||
var context = new BackupContext(Key, userMapping, writer); |
|||
|
|||
await foreach (var storedEvent in eventStore.QueryAllAsync(GetFilter(), ct: ct)) |
|||
{ |
|||
var @event = eventFormatter.Parse(storedEvent); |
|||
|
|||
if (@event.Payload is SquidexEvent squidexEvent && squidexEvent.Actor != null) |
|||
{ |
|||
context.UserMapping.Backup(squidexEvent.Actor); |
|||
} |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
await handler.BackupEventAsync(@event, context, ct); |
|||
} |
|||
|
|||
writer.WriteEvent(storedEvent, ct); |
|||
|
|||
job.HandledEvents = writer.WrittenEvents; |
|||
job.HandledAssets = writer.WrittenAttachments; |
|||
|
|||
lastTimestamp = await WritePeriodically(lastTimestamp); |
|||
} |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
ct.ThrowIfCancellationRequested(); |
|||
|
|||
await handler.BackupAsync(context, ct); |
|||
} |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
ct.ThrowIfCancellationRequested(); |
|||
|
|||
await handler.CompleteBackupAsync(context); |
|||
} |
|||
|
|||
await userMapping.StoreAsync(writer, userResolver, ct); |
|||
} |
|||
|
|||
stream.Position = 0; |
|||
|
|||
ct.ThrowIfCancellationRequested(); |
|||
|
|||
await backupArchiveStore.UploadAsync(job.Id, stream, ct); |
|||
} |
|||
|
|||
job.Status = JobStatus.Completed; |
|||
} |
|||
catch (OperationCanceledException) |
|||
{ |
|||
await RemoveAsync(job); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, "Faield to make backup with backup id '{backupId}'.", job.Id); |
|||
|
|||
job.Status = JobStatus.Failed; |
|||
} |
|||
finally |
|||
{ |
|||
job.Stopped = clock.GetCurrentInstant(); |
|||
|
|||
await state.WriteAsync(); |
|||
|
|||
currentJobToken?.Dispose(); |
|||
currentJobToken = null; |
|||
currentJob = null; |
|||
} |
|||
} |
|||
|
|||
private string GetFilter() |
|||
{ |
|||
return $"^[^\\-]*-{Regex.Escape(Key.ToString())}"; |
|||
} |
|||
|
|||
private async Task<Instant> WritePeriodically(Instant lastTimestamp) |
|||
{ |
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
if ((now - lastTimestamp) >= UpdateDuration) |
|||
{ |
|||
lastTimestamp = now; |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
|
|||
return lastTimestamp; |
|||
} |
|||
|
|||
public async Task DeleteAsync(DomainId id) |
|||
{ |
|||
var job = state.Value.Jobs.Find(x => x.Id == id); |
|||
|
|||
if (job == null) |
|||
{ |
|||
throw new DomainObjectNotFoundException(id.ToString()); |
|||
} |
|||
|
|||
if (currentJob == job) |
|||
{ |
|||
try |
|||
{ |
|||
currentJobToken?.Cancel(); |
|||
} |
|||
catch (ObjectDisposedException) |
|||
{ |
|||
return; |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
await RemoveAsync(job); |
|||
} |
|||
} |
|||
|
|||
private async Task RemoveAsync(BackupJob job) |
|||
{ |
|||
try |
|||
{ |
|||
#pragma warning disable MA0040 // Flow the cancellation token
|
|||
await backupArchiveStore.DeleteAsync(job.Id); |
|||
#pragma warning restore MA0040 // Flow the cancellation token
|
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, "Failed to make remove with backup id '{backupId}'.", job.Id); |
|||
} |
|||
|
|||
state.Value.Jobs.Remove(job); |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
|
|||
public Task<List<IBackupJob>> GetStateAsync() |
|||
{ |
|||
return Task.FromResult(state.Value.Jobs.OfType<IBackupJob>().ToList()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,305 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Text.RegularExpressions; |
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Backup.State; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.States; |
|||
using Squidex.Infrastructure.Tasks; |
|||
using Squidex.Infrastructure.Translations; |
|||
using Squidex.Shared.Users; |
|||
|
|||
#pragma warning disable MA0040 // Flow the cancellation token
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Backup |
|||
{ |
|||
public sealed class BackupProcessor |
|||
{ |
|||
private static readonly Duration UpdateDuration = Duration.FromSeconds(1); |
|||
private readonly IBackupArchiveLocation backupArchiveLocation; |
|||
private readonly IBackupArchiveStore backupArchiveStore; |
|||
private readonly IBackupHandlerFactory backupHandlerFactory; |
|||
private readonly IEventFormatter eventFormatter; |
|||
private readonly IEventStore eventStore; |
|||
private readonly IUserResolver userResolver; |
|||
private readonly ILogger<BackupProcessor> log; |
|||
private readonly SimpleState<BackupState> state; |
|||
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); |
|||
private readonly DomainId appId; |
|||
private Run? currentRun; |
|||
|
|||
// Use a run to store all state that is necessary for a single run.
|
|||
private sealed class Run : IDisposable |
|||
{ |
|||
private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); |
|||
private readonly CancellationTokenSource cancellationLinked; |
|||
|
|||
public IEnumerable<IBackupHandler> Handlers { get; init; } |
|||
|
|||
public RefToken Actor { get; init; } |
|||
|
|||
public BackupJob Job { get; init; } |
|||
|
|||
public CancellationToken CancellationToken => cancellationLinked.Token; |
|||
|
|||
public Run(CancellationToken ct) |
|||
{ |
|||
cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
cancellationSource.Dispose(); |
|||
cancellationLinked.Dispose(); |
|||
} |
|||
|
|||
public void Cancel() |
|||
{ |
|||
try |
|||
{ |
|||
cancellationSource.Cancel(); |
|||
} |
|||
catch (ObjectDisposedException) |
|||
{ |
|||
// Cancellation token might have been disposed, if the run is completed.
|
|||
} |
|||
} |
|||
} |
|||
|
|||
public IClock Clock { get; set; } = SystemClock.Instance; |
|||
|
|||
public BackupProcessor( |
|||
DomainId appId, |
|||
IBackupArchiveLocation backupArchiveLocation, |
|||
IBackupArchiveStore backupArchiveStore, |
|||
IBackupHandlerFactory backupHandlerFactory, |
|||
IEventFormatter eventFormatter, |
|||
IEventStore eventStore, |
|||
IPersistenceFactory<BackupState> persistenceFactory, |
|||
IUserResolver userResolver, |
|||
ILogger<BackupProcessor> log) |
|||
{ |
|||
this.appId = appId; |
|||
this.backupArchiveLocation = backupArchiveLocation; |
|||
this.backupArchiveStore = backupArchiveStore; |
|||
this.backupHandlerFactory = backupHandlerFactory; |
|||
this.eventFormatter = eventFormatter; |
|||
this.eventStore = eventStore; |
|||
this.userResolver = userResolver; |
|||
this.log = log; |
|||
|
|||
state = new SimpleState<BackupState>(persistenceFactory, GetType(), appId); |
|||
} |
|||
|
|||
public async Task LoadAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
await state.LoadAsync(ct); |
|||
|
|||
if (state.Value.Jobs.RemoveAll(x => x.Stopped == null) > 0) |
|||
{ |
|||
await state.WriteAsync(ct); |
|||
} |
|||
} |
|||
|
|||
public Task ClearAsync() |
|||
{ |
|||
return scheduler.ScheduleAsync(async _ => |
|||
{ |
|||
foreach (var backup in state.Value.Jobs) |
|||
{ |
|||
await backupArchiveStore.DeleteAsync(backup.Id, default); |
|||
} |
|||
|
|||
await state.ClearAsync(); |
|||
}); |
|||
} |
|||
|
|||
public Task BackupAsync(RefToken actor, |
|||
CancellationToken ct) |
|||
{ |
|||
return scheduler.ScheduleAsync(async _ => |
|||
{ |
|||
if (currentRun != null) |
|||
{ |
|||
throw new DomainException(T.Get("backups.alreadyRunning")); |
|||
} |
|||
|
|||
state.Value.EnsureCanStart(); |
|||
|
|||
// Set the current run first to indicate that we are running a rule at the moment.
|
|||
var run = currentRun = new Run(ct) |
|||
{ |
|||
Actor = actor, |
|||
Job = new BackupJob |
|||
{ |
|||
Id = DomainId.NewGuid(), |
|||
Started = Clock.GetCurrentInstant(), |
|||
Status = JobStatus.Started |
|||
}, |
|||
Handlers = backupHandlerFactory.CreateMany() |
|||
}; |
|||
|
|||
state.Value.Jobs.Insert(0, run.Job); |
|||
try |
|||
{ |
|||
await state.WriteAsync(run.CancellationToken); |
|||
|
|||
await ProcessAsync(run, run.CancellationToken); |
|||
} |
|||
finally |
|||
{ |
|||
// Unset the run to indicate that we are done.
|
|||
currentRun.Dispose(); |
|||
currentRun = null; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private async Task ProcessAsync(Run run, |
|||
CancellationToken ct) |
|||
{ |
|||
var lastTimestamp = run.Job.Started; |
|||
try |
|||
{ |
|||
await using (var stream = backupArchiveLocation.OpenStream(run.Job.Id)) |
|||
{ |
|||
using (var writer = await backupArchiveLocation.OpenWriterAsync(stream, ct)) |
|||
{ |
|||
await writer.WriteVersionAsync(); |
|||
|
|||
var backupUsers = new UserMapping(run.Actor); |
|||
var backupContext = new BackupContext(appId, backupUsers, writer); |
|||
|
|||
await foreach (var storedEvent in eventStore.QueryAllAsync(GetFilter(), ct: ct)) |
|||
{ |
|||
var @event = eventFormatter.Parse(storedEvent); |
|||
|
|||
if (@event.Payload is SquidexEvent squidexEvent && squidexEvent.Actor != null) |
|||
{ |
|||
backupUsers.Backup(squidexEvent.Actor); |
|||
} |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
await handler.BackupEventAsync(@event, backupContext, ct); |
|||
} |
|||
|
|||
writer.WriteEvent(storedEvent, ct); |
|||
|
|||
run.Job.HandledEvents = writer.WrittenEvents; |
|||
run.Job.HandledAssets = writer.WrittenAttachments; |
|||
|
|||
lastTimestamp = await WritePeriodically(lastTimestamp); |
|||
} |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
ct.ThrowIfCancellationRequested(); |
|||
|
|||
await handler.BackupAsync(backupContext, ct); |
|||
} |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
ct.ThrowIfCancellationRequested(); |
|||
|
|||
await handler.CompleteBackupAsync(backupContext); |
|||
} |
|||
|
|||
await backupUsers.StoreAsync(writer, userResolver, ct); |
|||
} |
|||
|
|||
stream.Position = 0; |
|||
|
|||
ct.ThrowIfCancellationRequested(); |
|||
|
|||
await backupArchiveStore.UploadAsync(run.Job.Id, stream, ct); |
|||
} |
|||
|
|||
run.Job.Status = JobStatus.Completed; |
|||
} |
|||
catch (OperationCanceledException) |
|||
{ |
|||
await RemoveAsync(run.Job); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, "Faield to make backup with backup id '{backupId}'.", run.Job.Id); |
|||
|
|||
run.Job.Status = JobStatus.Failed; |
|||
} |
|||
finally |
|||
{ |
|||
run.Job.Stopped = Clock.GetCurrentInstant(); |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
} |
|||
|
|||
private string GetFilter() |
|||
{ |
|||
return $"^[^\\-]*-{Regex.Escape(appId.ToString())}"; |
|||
} |
|||
|
|||
private async Task<Instant> WritePeriodically(Instant lastTimestamp) |
|||
{ |
|||
var now = Clock.GetCurrentInstant(); |
|||
|
|||
if ((now - lastTimestamp) >= UpdateDuration) |
|||
{ |
|||
lastTimestamp = now; |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
|
|||
return lastTimestamp; |
|||
} |
|||
|
|||
public Task DeleteAsync(DomainId id) |
|||
{ |
|||
return scheduler.ScheduleAsync(async _ => |
|||
{ |
|||
var job = state.Value.Jobs.Find(x => x.Id == id); |
|||
|
|||
if (job == null) |
|||
{ |
|||
throw new DomainObjectNotFoundException(id.ToString()); |
|||
} |
|||
|
|||
if (currentRun?.Job == job) |
|||
{ |
|||
currentRun.Cancel(); |
|||
} |
|||
else |
|||
{ |
|||
await RemoveAsync(job); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private async Task RemoveAsync(BackupJob job) |
|||
{ |
|||
try |
|||
{ |
|||
await backupArchiveStore.DeleteAsync(job.Id); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, "Failed to make remove with backup id '{backupId}'.", job.Id); |
|||
} |
|||
|
|||
state.Value.Jobs.Remove(job); |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Concurrent; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Hosting; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Messaging; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Backup |
|||
{ |
|||
public sealed class BackupWorker : |
|||
IMessageHandler<BackupRestore>, |
|||
IMessageHandler<BackupStart>, |
|||
IMessageHandler<BackupDelete>, |
|||
IMessageHandler<BackupClear>, |
|||
IInitializable |
|||
{ |
|||
private readonly Dictionary<DomainId, Task<BackupProcessor>> backupProcessors = new Dictionary<DomainId, Task<BackupProcessor>>(); |
|||
private readonly Func<DomainId, BackupProcessor> backupFactory; |
|||
private readonly RestoreProcessor restoreProcessor; |
|||
|
|||
public BackupWorker(IServiceProvider serviceProvider) |
|||
{ |
|||
var objectFactory = ActivatorUtilities.CreateFactory(typeof(BackupProcessor), new[] { typeof(DomainId) }); |
|||
|
|||
backupFactory = key => |
|||
{ |
|||
return (BackupProcessor)objectFactory(serviceProvider, new object[] { key }); |
|||
}; |
|||
|
|||
restoreProcessor = serviceProvider.GetRequiredService<RestoreProcessor>(); |
|||
} |
|||
|
|||
public Task InitializeAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
return restoreProcessor.LoadAsync(ct); |
|||
} |
|||
|
|||
public Task HandleAsync(BackupRestore message, |
|||
CancellationToken ct) |
|||
{ |
|||
return restoreProcessor.RestoreAsync(message.Url, message.Actor, message.NewAppName, ct); |
|||
} |
|||
|
|||
public async Task HandleAsync(BackupStart message, |
|||
CancellationToken ct) |
|||
{ |
|||
var processor = await GetBackupProcessorAsync(message.AppId); |
|||
|
|||
await processor.BackupAsync(message.Actor, ct); |
|||
} |
|||
|
|||
public async Task HandleAsync(BackupDelete message, |
|||
CancellationToken ct) |
|||
{ |
|||
var processor = await GetBackupProcessorAsync(message.AppId); |
|||
|
|||
await processor.DeleteAsync(message.Id); |
|||
} |
|||
|
|||
public async Task HandleAsync(BackupClear message, |
|||
CancellationToken ct) |
|||
{ |
|||
var processor = await GetBackupProcessorAsync(message.AppId); |
|||
|
|||
await processor.ClearAsync(); |
|||
} |
|||
|
|||
private Task<BackupProcessor> GetBackupProcessorAsync(DomainId appId) |
|||
{ |
|||
lock (backupProcessors) |
|||
{ |
|||
return backupProcessors.GetOrAdd(appId, async key => |
|||
{ |
|||
var processor = backupFactory(key); |
|||
|
|||
await processor.LoadAsync(default); |
|||
|
|||
return processor; |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,435 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks.Dataflow; |
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Orleans.Core; |
|||
using Squidex.Domain.Apps.Core.Apps; |
|||
using Squidex.Domain.Apps.Entities.Apps.Commands; |
|||
using Squidex.Domain.Apps.Entities.Backup.State; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Domain.Apps.Events.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.States; |
|||
using Squidex.Infrastructure.Tasks; |
|||
using Squidex.Infrastructure.Translations; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Backup |
|||
{ |
|||
public sealed class RestoreGrain : GrainBase, IRestoreGrain |
|||
{ |
|||
private readonly IBackupArchiveLocation backupArchiveLocation; |
|||
private readonly IBackupHandlerFactory backupHandlers; |
|||
private readonly IClock clock; |
|||
private readonly ICommandBus commandBus; |
|||
private readonly IEventFormatter eventFormatter; |
|||
private readonly IEventStore eventStore; |
|||
private readonly IEventStreamNames eventStreams; |
|||
private readonly IGrainState<BackupRestoreState> state; |
|||
private readonly ILogger<RestoreGrain> log; |
|||
private readonly IUserResolver userResolver; |
|||
private RestoreContext runningContext; |
|||
private StreamMapper runningStreamMapper; |
|||
|
|||
private RestoreJob CurrentJob |
|||
{ |
|||
get => state.Value.Job; |
|||
} |
|||
|
|||
public RestoreGrain( |
|||
IBackupArchiveLocation backupArchiveLocation, |
|||
IBackupHandlerFactory backupHandlers, |
|||
IClock clock, |
|||
ICommandBus commandBus, |
|||
IEventFormatter eventFormatter, |
|||
IEventStore eventStore, |
|||
IEventStreamNames eventStreams, |
|||
IGrainIdentity identity, |
|||
IGrainState<BackupRestoreState> state, |
|||
IUserResolver userResolver, |
|||
ILogger<RestoreGrain> log) |
|||
: base(identity) |
|||
{ |
|||
this.backupArchiveLocation = backupArchiveLocation; |
|||
this.backupHandlers = backupHandlers; |
|||
this.clock = clock; |
|||
this.commandBus = commandBus; |
|||
this.eventFormatter = eventFormatter; |
|||
this.eventStore = eventStore; |
|||
this.eventStreams = eventStreams; |
|||
this.state = state; |
|||
this.userResolver = userResolver; |
|||
this.log = log; |
|||
} |
|||
|
|||
public override Task OnActivateAsync() |
|||
{ |
|||
RecoverAfterRestartAsync().Forget(); |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
private async Task RecoverAfterRestartAsync() |
|||
{ |
|||
if (CurrentJob?.Status == JobStatus.Started) |
|||
{ |
|||
Log("Failed due application restart"); |
|||
|
|||
CurrentJob.Status = JobStatus.Failed; |
|||
|
|||
await state.WriteAsync(); |
|||
} |
|||
} |
|||
|
|||
public async Task RestoreAsync(Uri url, RefToken actor, string? newAppName = null) |
|||
{ |
|||
Guard.NotNull(url); |
|||
Guard.NotNull(actor); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(newAppName)) |
|||
{ |
|||
Guard.ValidSlug(newAppName); |
|||
} |
|||
|
|||
if (CurrentJob?.Status == JobStatus.Started) |
|||
{ |
|||
throw new DomainException(T.Get("backups.restoreRunning")); |
|||
} |
|||
|
|||
state.Value.Job = new RestoreJob |
|||
{ |
|||
Id = DomainId.NewGuid(), |
|||
NewAppName = newAppName, |
|||
Actor = actor, |
|||
Started = clock.GetCurrentInstant(), |
|||
Status = JobStatus.Started, |
|||
Url = url |
|||
}; |
|||
|
|||
await state.WriteAsync(); |
|||
|
|||
#pragma warning disable MA0042 // Do not use blocking calls in an async method
|
|||
Process(); |
|||
#pragma warning restore MA0042 // Do not use blocking calls in an async method
|
|||
} |
|||
|
|||
private void Process() |
|||
{ |
|||
ProcessAsync().Forget(); |
|||
} |
|||
|
|||
private async Task ProcessAsync() |
|||
{ |
|||
var handlers = backupHandlers.CreateMany(); |
|||
|
|||
var ct = default(CancellationToken); |
|||
|
|||
using (Telemetry.Activities.StartActivity("RestoreBackup")) |
|||
{ |
|||
try |
|||
{ |
|||
Log("Started. The restore process has the following steps:"); |
|||
Log(" * Download backup"); |
|||
Log(" * Restore events and attachments."); |
|||
Log(" * Restore all objects like app, schemas and contents"); |
|||
Log(" * Complete the restore operation for all objects"); |
|||
|
|||
log.LogInformation("Backup with job id {backupId} with from URL '{url}' started.", |
|||
CurrentJob.Id, |
|||
CurrentJob.Url); |
|||
|
|||
using (var reader = await DownloadAsync()) |
|||
{ |
|||
await reader.CheckCompatibilityAsync(); |
|||
|
|||
using (Telemetry.Activities.StartActivity("ReadEvents")) |
|||
{ |
|||
await ReadEventsAsync(reader, handlers); |
|||
} |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/RestoreAsync")) |
|||
{ |
|||
await handler.RestoreAsync(runningContext, ct); |
|||
} |
|||
|
|||
Log($"Restored {handler.Name}"); |
|||
} |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/CompleteRestoreAsync")) |
|||
{ |
|||
await handler.CompleteRestoreAsync(runningContext, CurrentJob.NewAppName!); |
|||
} |
|||
|
|||
Log($"Completed {handler.Name}"); |
|||
} |
|||
} |
|||
|
|||
await AssignContributorAsync(); |
|||
|
|||
CurrentJob.Status = JobStatus.Completed; |
|||
|
|||
Log("Completed, Yeah!"); |
|||
|
|||
log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", |
|||
CurrentJob.Id, |
|||
CurrentJob.Url); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
switch (ex) |
|||
{ |
|||
case BackupRestoreException backupException: |
|||
Log(backupException.Message); |
|||
break; |
|||
case FileNotFoundException fileNotFoundException: |
|||
Log(fileNotFoundException.Message); |
|||
break; |
|||
default: |
|||
Log("Failed with internal error"); |
|||
break; |
|||
} |
|||
|
|||
await CleanupAsync(handlers); |
|||
|
|||
CurrentJob.Status = JobStatus.Failed; |
|||
|
|||
log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", |
|||
CurrentJob.Id, |
|||
CurrentJob.Url); |
|||
} |
|||
finally |
|||
{ |
|||
CurrentJob.Stopped = clock.GetCurrentInstant(); |
|||
|
|||
await state.WriteAsync(); |
|||
|
|||
runningStreamMapper = null!; |
|||
runningContext = null!; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task AssignContributorAsync() |
|||
{ |
|||
var actor = CurrentJob.Actor; |
|||
|
|||
if (actor?.IsUser == true) |
|||
{ |
|||
try |
|||
{ |
|||
await commandBus.PublishAsync(new AssignContributor |
|||
{ |
|||
Actor = actor, |
|||
AppId = CurrentJob.AppId, |
|||
ContributorId = actor.Identifier, |
|||
Restoring = true, |
|||
Role = Role.Owner |
|||
}); |
|||
|
|||
Log("Assigned current user."); |
|||
} |
|||
catch (DomainException ex) |
|||
{ |
|||
Log($"Failed to assign contributor: {ex.Message}"); |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
Log("Current user not assigned because restore was triggered by client."); |
|||
} |
|||
} |
|||
|
|||
private async Task CleanupAsync(IEnumerable<IBackupHandler> handlers) |
|||
{ |
|||
if (CurrentJob.AppId != null) |
|||
{ |
|||
var appId = CurrentJob.AppId.Id; |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
try |
|||
{ |
|||
await handler.CleanupRestoreErrorAsync(appId); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, "Failed to clean up restore."); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task<IBackupReader> DownloadAsync() |
|||
{ |
|||
using (Telemetry.Activities.StartActivity("Download")) |
|||
{ |
|||
Log("Downloading Backup"); |
|||
|
|||
var reader = await backupArchiveLocation.OpenReaderAsync(CurrentJob.Url, CurrentJob.Id); |
|||
|
|||
Log("Downloaded Backup"); |
|||
|
|||
return reader; |
|||
} |
|||
} |
|||
|
|||
private async Task ReadEventsAsync(IBackupReader reader, IEnumerable<IBackupHandler> handlers) |
|||
{ |
|||
const int BatchSize = 100; |
|||
|
|||
var handled = 0; |
|||
|
|||
var writeBlock = new ActionBlock<(string, Envelope<IEvent>)[]>(async batch => |
|||
{ |
|||
try |
|||
{ |
|||
var commits = new List<EventCommit>(batch.Length); |
|||
|
|||
foreach (var (stream, @event) in batch) |
|||
{ |
|||
var offset = runningStreamMapper.GetStreamOffset(stream); |
|||
|
|||
commits.Add(EventCommit.Create(stream, offset, @event, eventFormatter)); |
|||
} |
|||
|
|||
await eventStore.AppendUnsafeAsync(commits); |
|||
|
|||
handled += commits.Count; |
|||
|
|||
Log($"Reading {reader.ReadEvents}/{handled} events and {reader.ReadAttachments} attachments completed.", true); |
|||
} |
|||
catch (OperationCanceledException ex) |
|||
{ |
|||
// Dataflow swallows operation cancelled exception.
|
|||
throw new AggregateException(ex); |
|||
} |
|||
}, new ExecutionDataflowBlockOptions |
|||
{ |
|||
MaxDegreeOfParallelism = 1, |
|||
MaxMessagesPerTask = 1, |
|||
BoundedCapacity = 2 |
|||
}); |
|||
|
|||
var batchBlock = new BatchBlock<(string, Envelope<IEvent>)>(BatchSize, new GroupingDataflowBlockOptions |
|||
{ |
|||
BoundedCapacity = BatchSize * 2 |
|||
}); |
|||
|
|||
batchBlock.BidirectionalLinkTo(writeBlock); |
|||
|
|||
await foreach (var job in reader.ReadEventsAsync(eventStreams, eventFormatter)) |
|||
{ |
|||
var newStream = await HandleEventAsync(reader, handlers, job.Stream, job.Event); |
|||
|
|||
if (newStream != null) |
|||
{ |
|||
if (!await batchBlock.SendAsync((newStream, job.Event))) |
|||
{ |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
batchBlock.Complete(); |
|||
|
|||
await writeBlock.Completion; |
|||
} |
|||
|
|||
private async Task<string?> HandleEventAsync(IBackupReader reader, IEnumerable<IBackupHandler> handlers, string stream, Envelope<IEvent> @event, |
|||
CancellationToken ct = default) |
|||
{ |
|||
if (@event.Payload is AppCreated appCreated) |
|||
{ |
|||
var previousAppId = appCreated.AppId.Id; |
|||
|
|||
if (!string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) |
|||
{ |
|||
appCreated.Name = CurrentJob.NewAppName; |
|||
|
|||
CurrentJob.AppId = NamedId.Of(DomainId.NewGuid(), CurrentJob.NewAppName); |
|||
} |
|||
else |
|||
{ |
|||
CurrentJob.AppId = NamedId.Of(DomainId.NewGuid(), appCreated.Name); |
|||
} |
|||
|
|||
await CreateContextAsync(reader, previousAppId); |
|||
} |
|||
|
|||
if (@event.Payload is SquidexEvent squidexEvent && squidexEvent.Actor != null) |
|||
{ |
|||
if (runningContext.UserMapping.TryMap(squidexEvent.Actor, out var newUser)) |
|||
{ |
|||
squidexEvent.Actor = newUser; |
|||
} |
|||
} |
|||
|
|||
if (@event.Payload is AppEvent appEvent) |
|||
{ |
|||
appEvent.AppId = CurrentJob.AppId; |
|||
} |
|||
|
|||
var (newStream, id) = runningStreamMapper.Map(stream); |
|||
|
|||
@event.SetAggregateId(id); |
|||
@event.SetRestored(); |
|||
|
|||
foreach (var handler in handlers) |
|||
{ |
|||
if (!await handler.RestoreEventAsync(@event, runningContext, ct)) |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
return newStream; |
|||
} |
|||
|
|||
private async Task CreateContextAsync(IBackupReader reader, DomainId previousAppId) |
|||
{ |
|||
var userMapping = new UserMapping(CurrentJob.Actor); |
|||
|
|||
using (Telemetry.Activities.StartActivity("CreateUsers")) |
|||
{ |
|||
Log("Creating Users"); |
|||
|
|||
await userMapping.RestoreAsync(reader, userResolver); |
|||
|
|||
Log("Created Users"); |
|||
} |
|||
|
|||
runningContext = new RestoreContext(CurrentJob.AppId.Id, userMapping, reader, previousAppId); |
|||
runningStreamMapper = new StreamMapper(runningContext); |
|||
} |
|||
|
|||
private void Log(string message, bool replace = false) |
|||
{ |
|||
if (replace && CurrentJob.Log.Count > 0) |
|||
{ |
|||
CurrentJob.Log[^1] = $"{clock.GetCurrentInstant()}: {message}"; |
|||
} |
|||
else |
|||
{ |
|||
CurrentJob.Log.Add($"{clock.GetCurrentInstant()}: {message}"); |
|||
} |
|||
} |
|||
|
|||
public Task<IRestoreJob> GetStateAsync() |
|||
{ |
|||
return Task.FromResult<IRestoreJob>(CurrentJob); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,465 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks.Dataflow; |
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.Apps; |
|||
using Squidex.Domain.Apps.Entities.Apps.Commands; |
|||
using Squidex.Domain.Apps.Entities.Backup.State; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Domain.Apps.Events.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.States; |
|||
using Squidex.Infrastructure.Tasks; |
|||
using Squidex.Infrastructure.Translations; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Backup |
|||
{ |
|||
public sealed class RestoreProcessor |
|||
{ |
|||
private readonly IBackupArchiveLocation backupArchiveLocation; |
|||
private readonly IBackupHandlerFactory backupHandlerFactory; |
|||
private readonly ICommandBus commandBus; |
|||
private readonly IEventFormatter eventFormatter; |
|||
private readonly IEventStore eventStore; |
|||
private readonly IEventStreamNames eventStreamNames; |
|||
private readonly IUserResolver userResolver; |
|||
private readonly ILogger<RestoreProcessor> log; |
|||
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); |
|||
private readonly SimpleState<BackupRestoreState> state; |
|||
private Run? currentRun; |
|||
|
|||
// Use a run to store all state that is necessary for a single run.
|
|||
private sealed class Run : IDisposable |
|||
{ |
|||
private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); |
|||
private readonly CancellationTokenSource cancellationLinked; |
|||
private readonly IClock clock; |
|||
|
|||
public IEnumerable<IBackupHandler> Handlers { get; init; } |
|||
|
|||
public IBackupReader Reader { get; set; } |
|||
|
|||
public RestoreJob Job { get; init; } |
|||
|
|||
public RestoreContext Context { get; set; } |
|||
|
|||
public StreamMapper StreamMapper { get; set; } |
|||
|
|||
public CancellationToken CancellationToken => cancellationLinked.Token; |
|||
|
|||
public Run(IClock clock, CancellationToken ct) |
|||
{ |
|||
cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); |
|||
|
|||
this.clock = clock; |
|||
} |
|||
|
|||
public void Log(string message, bool replace = false) |
|||
{ |
|||
if (replace && Job.Log.Count > 0) |
|||
{ |
|||
Job.Log[^1] = $"{clock.GetCurrentInstant()}: {message}"; |
|||
} |
|||
else |
|||
{ |
|||
Job.Log.Add($"{clock.GetCurrentInstant()}: {message}"); |
|||
} |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
Reader?.Dispose(); |
|||
|
|||
cancellationSource.Dispose(); |
|||
cancellationLinked.Dispose(); |
|||
} |
|||
|
|||
public void Cancel() |
|||
{ |
|||
try |
|||
{ |
|||
cancellationSource.Cancel(); |
|||
} |
|||
catch (ObjectDisposedException) |
|||
{ |
|||
// Cancellation token might have been disposed, if the run is completed.
|
|||
} |
|||
} |
|||
} |
|||
|
|||
public IClock Clock { get; set; } = SystemClock.Instance; |
|||
|
|||
public RestoreProcessor( |
|||
IBackupArchiveLocation backupArchiveLocation, |
|||
IBackupHandlerFactory backupHandlerFactory, |
|||
ICommandBus commandBus, |
|||
IEventFormatter eventFormatter, |
|||
IEventStore eventStore, |
|||
IEventStreamNames eventStreamNames, |
|||
IPersistenceFactory<BackupRestoreState> persistenceFactory, |
|||
IUserResolver userResolver, |
|||
ILogger<RestoreProcessor> log) |
|||
{ |
|||
this.backupArchiveLocation = backupArchiveLocation; |
|||
this.backupHandlerFactory = backupHandlerFactory; |
|||
this.commandBus = commandBus; |
|||
this.eventFormatter = eventFormatter; |
|||
this.eventStore = eventStore; |
|||
this.eventStreamNames = eventStreamNames; |
|||
this.userResolver = userResolver; |
|||
this.log = log; |
|||
|
|||
state = new SimpleState<BackupRestoreState>(persistenceFactory, GetType(), "Default"); |
|||
} |
|||
|
|||
public async Task LoadAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
await state.LoadAsync(ct); |
|||
|
|||
if (state.Value.Job?.Status == JobStatus.Started) |
|||
{ |
|||
state.Value.Job.Status = JobStatus.Failed; |
|||
|
|||
await state.WriteAsync(ct); |
|||
} |
|||
} |
|||
|
|||
public Task RestoreAsync(Uri url, RefToken actor, string? newAppName, |
|||
CancellationToken ct) |
|||
{ |
|||
Guard.NotNull(url); |
|||
Guard.NotNull(actor); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(newAppName)) |
|||
{ |
|||
Guard.ValidSlug(newAppName); |
|||
} |
|||
|
|||
return scheduler.ScheduleAsync(async ct => |
|||
{ |
|||
if (currentRun != null) |
|||
{ |
|||
throw new DomainException(T.Get("backups.restoreRunning")); |
|||
} |
|||
|
|||
state.Value.Job?.EnsureCanStart(); |
|||
|
|||
// Set the current run first to indicate that we are running a rule at the moment.
|
|||
var run = currentRun = new Run(Clock, ct) |
|||
{ |
|||
Job = new RestoreJob |
|||
{ |
|||
Id = DomainId.NewGuid(), |
|||
NewAppName = newAppName, |
|||
Actor = actor, |
|||
Started = Clock.GetCurrentInstant(), |
|||
Status = JobStatus.Started, |
|||
Url = url |
|||
}, |
|||
Handlers = backupHandlerFactory.CreateMany() |
|||
}; |
|||
|
|||
state.Value.Job = run.Job; |
|||
try |
|||
{ |
|||
await state.WriteAsync(run.CancellationToken); |
|||
|
|||
await ProcessAsync(run, run.CancellationToken); |
|||
} |
|||
finally |
|||
{ |
|||
// Unset the run to indicate that we are done.
|
|||
currentRun.Dispose(); |
|||
currentRun = null; |
|||
} |
|||
}, ct); |
|||
} |
|||
|
|||
private async Task ProcessAsync(Run run, |
|||
CancellationToken ct) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity("RestoreBackup")) |
|||
{ |
|||
try |
|||
{ |
|||
run.Log("Started. The restore process has the following steps:"); |
|||
run.Log(" * Download backup"); |
|||
run.Log(" * Restore events and attachments."); |
|||
run.Log(" * Restore all objects like app, schemas and contents"); |
|||
run.Log(" * Complete the restore operation for all objects"); |
|||
|
|||
log.LogInformation("Backup with job id {backupId} with from URL '{url}' started.", run.Job.Id, run.Job.Url); |
|||
|
|||
run.Reader = await DownloadAsync(run, ct); |
|||
|
|||
await run.Reader.CheckCompatibilityAsync(); |
|||
|
|||
using (Telemetry.Activities.StartActivity("ReadEvents")) |
|||
{ |
|||
await ReadEventsAsync(run, ct); |
|||
} |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/RestoreAsync")) |
|||
{ |
|||
await handler.RestoreAsync(run.Context, ct); |
|||
} |
|||
|
|||
run.Log($"Restored {handler.Name}"); |
|||
} |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/CompleteRestoreAsync")) |
|||
{ |
|||
await handler.CompleteRestoreAsync(run.Context, run.Job.NewAppName!); |
|||
} |
|||
|
|||
run.Log($"Completed {handler.Name}"); |
|||
} |
|||
|
|||
await AssignContributorAsync(run); |
|||
|
|||
run.Job.Status = JobStatus.Completed; |
|||
run.Log("Completed, Yeah!"); |
|||
|
|||
log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", run.Job.Id, run.Job.Url); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
switch (ex) |
|||
{ |
|||
case BackupRestoreException backupException: |
|||
run.Log(backupException.Message); |
|||
break; |
|||
case FileNotFoundException fileNotFoundException: |
|||
run.Log(fileNotFoundException.Message); |
|||
break; |
|||
default: |
|||
run.Log("Failed with internal error"); |
|||
break; |
|||
} |
|||
|
|||
await CleanupAsync(run); |
|||
|
|||
run.Job.Status = JobStatus.Failed; |
|||
|
|||
log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", run.Job.Id, run.Job.Url); |
|||
} |
|||
finally |
|||
{ |
|||
run.Job.Stopped = Clock.GetCurrentInstant(); |
|||
|
|||
await state.WriteAsync(ct); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task AssignContributorAsync(Run run) |
|||
{ |
|||
if (run.Job.Actor?.IsUser != true) |
|||
{ |
|||
run.Log("Current user not assigned because restore was triggered by client."); |
|||
return; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var command = new AssignContributor |
|||
{ |
|||
Actor = run.Job.Actor, |
|||
AppId = run.Job.AppId, |
|||
ContributorId = run.Job.Actor.Identifier, |
|||
Restoring = true, |
|||
Role = Role.Owner |
|||
}; |
|||
|
|||
await commandBus.PublishAsync(command, default); |
|||
|
|||
run.Log("Assigned current user."); |
|||
} |
|||
catch (DomainException ex) |
|||
{ |
|||
run.Log($"Failed to assign contributor: {ex.Message}"); |
|||
} |
|||
} |
|||
|
|||
private async Task CleanupAsync(Run run) |
|||
{ |
|||
if (run.Job.AppId == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
try |
|||
{ |
|||
await handler.CleanupRestoreErrorAsync(run.Job.AppId.Id); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, "Failed to clean up restore."); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task<IBackupReader> DownloadAsync(Run run, |
|||
CancellationToken ct) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity("Download")) |
|||
{ |
|||
run.Log("Downloading Backup"); |
|||
|
|||
var reader = await backupArchiveLocation.OpenReaderAsync(run.Job.Url, run.Job.Id, ct); |
|||
|
|||
run.Log("Downloaded Backup"); |
|||
|
|||
return reader; |
|||
} |
|||
} |
|||
|
|||
private async Task ReadEventsAsync(Run run, |
|||
CancellationToken ct) |
|||
{ |
|||
const int BatchSize = 100; |
|||
|
|||
var handled = 0; |
|||
|
|||
var writeBlock = new ActionBlock<(string, Envelope<IEvent>)[]>(async batch => |
|||
{ |
|||
try |
|||
{ |
|||
var commits = new List<EventCommit>(batch.Length); |
|||
|
|||
foreach (var (stream, @event) in batch) |
|||
{ |
|||
var offset = run.StreamMapper.GetStreamOffset(stream); |
|||
|
|||
commits.Add(EventCommit.Create(stream, offset, @event, eventFormatter)); |
|||
} |
|||
|
|||
await eventStore.AppendUnsafeAsync(commits, ct); |
|||
|
|||
handled += commits.Count; |
|||
|
|||
run.Log($"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true); |
|||
} |
|||
catch (OperationCanceledException ex) |
|||
{ |
|||
// Dataflow swallows operation cancelled exception.
|
|||
throw new AggregateException(ex); |
|||
} |
|||
}, new ExecutionDataflowBlockOptions |
|||
{ |
|||
MaxDegreeOfParallelism = 1, |
|||
MaxMessagesPerTask = 1, |
|||
BoundedCapacity = 2 |
|||
}); |
|||
|
|||
var batchBlock = new BatchBlock<(string, Envelope<IEvent>)>(BatchSize, new GroupingDataflowBlockOptions |
|||
{ |
|||
BoundedCapacity = BatchSize * 2 |
|||
}); |
|||
|
|||
batchBlock.BidirectionalLinkTo(writeBlock); |
|||
|
|||
await foreach (var job in run.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct)) |
|||
{ |
|||
var newStream = await HandleEventAsync(run, job.Stream, job.Event, ct); |
|||
|
|||
if (newStream != null) |
|||
{ |
|||
if (!await batchBlock.SendAsync((newStream, job.Event))) |
|||
{ |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
batchBlock.Complete(); |
|||
|
|||
await writeBlock.Completion; |
|||
} |
|||
|
|||
private async Task<string?> HandleEventAsync(Run run, string stream, Envelope<IEvent> @event, |
|||
CancellationToken ct = default) |
|||
{ |
|||
if (@event.Payload is AppCreated appCreated) |
|||
{ |
|||
var previousAppId = appCreated.AppId.Id; |
|||
|
|||
if (!string.IsNullOrWhiteSpace(run.Job.NewAppName)) |
|||
{ |
|||
appCreated.Name = run.Job.NewAppName; |
|||
|
|||
run.Job.AppId = NamedId.Of(DomainId.NewGuid(), run.Job.NewAppName); |
|||
} |
|||
else |
|||
{ |
|||
run.Job.AppId = NamedId.Of(DomainId.NewGuid(), appCreated.Name); |
|||
} |
|||
|
|||
await CreateContextAsync(run, previousAppId, ct); |
|||
|
|||
run.StreamMapper = new StreamMapper(run.Context); |
|||
} |
|||
|
|||
if (@event.Payload is SquidexEvent squidexEvent && squidexEvent.Actor != null) |
|||
{ |
|||
if (run.Context.UserMapping.TryMap(squidexEvent.Actor, out var newUser)) |
|||
{ |
|||
squidexEvent.Actor = newUser; |
|||
} |
|||
} |
|||
|
|||
if (@event.Payload is AppEvent appEvent) |
|||
{ |
|||
appEvent.AppId = run.Job.AppId; |
|||
} |
|||
|
|||
var (newStream, id) = run.StreamMapper.Map(stream); |
|||
|
|||
@event.SetAggregateId(id); |
|||
@event.SetRestored(); |
|||
|
|||
foreach (var handler in run.Handlers) |
|||
{ |
|||
if (!await handler.RestoreEventAsync(@event, run.Context, ct)) |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
return newStream; |
|||
} |
|||
|
|||
private async Task CreateContextAsync(Run run, DomainId previousAppId, |
|||
CancellationToken ct) |
|||
{ |
|||
var userMapping = new UserMapping(run.Job.Actor); |
|||
|
|||
using (Telemetry.Activities.StartActivity("CreateUsers")) |
|||
{ |
|||
run.Log("Creating Users"); |
|||
|
|||
await userMapping.RestoreAsync(run.Reader, userResolver, ct); |
|||
|
|||
run.Log("Created Users"); |
|||
} |
|||
|
|||
run.Context = new RestoreContext(run.Job.AppId.Id, userMapping, run.Reader, previousAppId); |
|||
} |
|||
} |
|||
} |
|||
@ -1,21 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject |
|||
{ |
|||
public interface ICommentsGrain : IGrainWithStringKey |
|||
{ |
|||
Task<CommandResult> ExecuteAsync(CommentsCommand command); |
|||
|
|||
Task<CommentsResult> GetCommentsAsync(long sinceVersion = EtagVersion.Any); |
|||
} |
|||
} |
|||
@ -1,35 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments |
|||
{ |
|||
public sealed class GrainWatchingService : IWatchingService |
|||
{ |
|||
private readonly IGrainFactory grainFactory; |
|||
|
|||
public GrainWatchingService(IGrainFactory grainFactory) |
|||
{ |
|||
this.grainFactory = grainFactory; |
|||
} |
|||
|
|||
public Task<string[]> GetWatchingUsersAsync(DomainId appId, string resource, string userId) |
|||
{ |
|||
Guard.NotNullOrEmpty(resource); |
|||
Guard.NotNullOrEmpty(userId); |
|||
|
|||
return GetGrain(appId).GetWatchingUsersAsync(resource, userId); |
|||
} |
|||
|
|||
private IWatchingGrain GetGrain(DomainId appId) |
|||
{ |
|||
return grainFactory.GetGrain<IWatchingGrain>(appId.ToString()); |
|||
} |
|||
} |
|||
} |
|||
@ -1,16 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments |
|||
{ |
|||
public interface IWatchingGrain : IGrainWithStringKey |
|||
{ |
|||
Task<string[]> GetWatchingUsersAsync(string resource, string userId); |
|||
} |
|||
} |
|||
@ -1,86 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Orleans.Core; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments |
|||
{ |
|||
public sealed class WatchingGrain : GrainBase, IWatchingGrain |
|||
{ |
|||
private static readonly Duration Timeout = Duration.FromMinutes(1); |
|||
private readonly Dictionary<string, Dictionary<string, Instant>> users = new Dictionary<string, Dictionary<string, Instant>>(); |
|||
private readonly IClock clock; |
|||
|
|||
public WatchingGrain(IGrainIdentity grainIdentity, IClock clock) |
|||
: base(grainIdentity) |
|||
{ |
|||
this.clock = clock; |
|||
} |
|||
|
|||
public override Task OnActivateAsync() |
|||
{ |
|||
var time = TimeSpan.FromSeconds(30); |
|||
|
|||
RegisterTimer(x => |
|||
{ |
|||
Cleanup(); |
|||
|
|||
return Task.CompletedTask; |
|||
}, null, time, time); |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task<string[]> GetWatchingUsersAsync(string resource, string userId) |
|||
{ |
|||
Guard.NotNullOrEmpty(resource); |
|||
Guard.NotNullOrEmpty(userId); |
|||
|
|||
var usersByResource = users.GetOrAddNew(resource); |
|||
|
|||
usersByResource[userId] = clock.GetCurrentInstant(); |
|||
|
|||
return Task.FromResult(usersByResource.Keys.ToArray()); |
|||
} |
|||
|
|||
public void Cleanup() |
|||
{ |
|||
if (users.Count == 0) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
foreach (var (resource, usersByResource) in users.ToList()) |
|||
{ |
|||
foreach (var (userId, lastSeen) in usersByResource.ToList()) |
|||
{ |
|||
var timeSinceLastSeen = now - lastSeen; |
|||
|
|||
if (timeSinceLastSeen > Timeout) |
|||
{ |
|||
usersByResource.Remove(userId); |
|||
} |
|||
} |
|||
|
|||
if (usersByResource.Count == 0) |
|||
{ |
|||
users.Remove(resource); |
|||
} |
|||
} |
|||
|
|||
if (users.Count == 0) |
|||
{ |
|||
TryDeactivateOnIdle(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments |
|||
{ |
|||
public sealed class WatchingService : IWatchingService |
|||
{ |
|||
private readonly IPersistenceFactory<State> persistenceFactory; |
|||
|
|||
[CollectionName("Watches")] |
|||
public sealed class State |
|||
{ |
|||
private static readonly Duration Timeout = Duration.FromMinutes(1); |
|||
|
|||
public Dictionary<string, Instant> Users { get; } = new Dictionary<string, Instant>(); |
|||
|
|||
public string[] Add(string watcherId, IClock clock) |
|||
{ |
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
foreach (var (userId, lastSeen) in Users.ToList()) |
|||
{ |
|||
var timeSinceLastSeen = now - lastSeen; |
|||
|
|||
if (timeSinceLastSeen > Timeout) |
|||
{ |
|||
Users.Remove(userId); |
|||
} |
|||
} |
|||
|
|||
Users[watcherId] = now; |
|||
|
|||
return Users.Keys.ToArray(); |
|||
} |
|||
} |
|||
|
|||
public IClock Clock { get; set; } = SystemClock.Instance; |
|||
|
|||
public WatchingService(IPersistenceFactory<State> persistenceFactory) |
|||
{ |
|||
this.persistenceFactory = persistenceFactory; |
|||
} |
|||
|
|||
public async Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var state = new SimpleState<State>(persistenceFactory, GetType(), $"{appId}_{resource}"); |
|||
|
|||
await state.LoadAsync(ct); |
|||
|
|||
return await state.UpdateAsync(x => x.Add(userId, Clock), ct: ct); |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue