Browse Source

Refactoring/no orleans (#894)

* 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
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
00c5371948
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  2. 2
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs
  3. 2
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  4. 5
      backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSource.cs
  5. 23
      backend/extensions/Squidex.Extensions/Samples/Middleware/DoubleLinkedContentMiddleware.cs
  6. 3
      backend/i18n/frontend_en.json
  7. 3
      backend/i18n/frontend_it.json
  8. 3
      backend/i18n/frontend_nl.json
  9. 3
      backend/i18n/frontend_zh.json
  10. 3
      backend/i18n/source/frontend_en.json
  11. 4
      backend/src/Migrations/MigrationPath.cs
  12. 30
      backend/src/Migrations/Migrations/StartEventConsumers.cs
  13. 30
      backend/src/Migrations/Migrations/StopEventConsumers.cs
  14. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ExcludeChangedTypes.cs
  15. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  16. 25
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  17. 24
      backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs
  18. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
  19. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueValidator.cs
  20. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/RootContext.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs
  22. 43
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs
  23. 16
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository.cs
  24. 45
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository.cs
  25. 8
      backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs
  26. 15
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppPermanentDeleter.cs
  27. 142
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs
  28. 120
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs
  29. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  30. 37
      backend/src/Squidex.Domain.Apps.Entities/Apps/Diagnostics/OrleansAppsHealthCheck.cs
  31. 31
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
  32. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs
  33. 55
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs
  34. 27
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObjectGrain.cs
  35. 16
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/IAppGrain.cs
  36. 12
      backend/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs
  37. 108
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsCacheGrain.cs
  38. 154
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
  39. 21
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsCacheGrain.cs
  40. 3
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
  41. 11
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  42. 6
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlanBillingManager.cs
  43. 18
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IUsageNotifierGrain.cs
  44. 14
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/Messages.cs
  45. 6
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/NoopAppPlanBillingManager.cs
  46. 12
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs
  47. 57
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
  48. 32
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierWorker.cs
  49. 7
      backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs
  50. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs
  51. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs
  52. 26
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupProcess.cs
  53. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetTagsDeleter.cs
  54. 67
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs
  55. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs
  56. 42
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs
  57. 40
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObjectGrain.cs
  58. 21
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs
  59. 39
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObjectGrain.cs
  60. 26
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs
  61. 17
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/IAssetGrain.cs
  62. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs
  63. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/FileTypeAssetMetadataSource.cs
  64. 5
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetLoader.cs
  65. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetMetadataSource.cs
  66. 9
      backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs
  67. 15
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs
  68. 52
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetLoader.cs
  69. 18
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs
  70. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs
  71. 9
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs
  72. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs
  73. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/SvgAssetMetadataSource.cs
  74. 285
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  75. 305
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs
  76. 6
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs
  77. 69
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs
  78. 90
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs
  79. 6
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs
  80. 4
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs
  81. 6
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs
  82. 15
      backend/src/Squidex.Domain.Apps.Entities/Backup/Messages.cs
  83. 435
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs
  84. 465
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs
  85. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs
  86. 16
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs
  87. 9
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs
  88. 22
      backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs
  89. 5
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs
  90. 17
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs
  91. 26
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs
  92. 45
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs
  93. 21
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/ICommentsGrain.cs
  94. 35
      backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs
  95. 5
      backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs
  96. 16
      backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs
  97. 3
      backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs
  98. 86
      backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.cs
  99. 62
      backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingService.cs
  100. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

2
backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs

@ -64,7 +64,7 @@ namespace Squidex.Extensions.Actions.Comment
command.FromRule = true;
await commandBus.PublishAsync(command);
await commandBus.PublishAsync(command, ct);
return Result.Success($"Commented: {job.Text}");
}

2
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs

@ -76,7 +76,7 @@ namespace Squidex.Extensions.Actions.CreateContent
command.FromRule = true;
await commandBus.PublishAsync(command);
await commandBus.PublishAsync(command, ct);
return Result.Success($"Created to: {job.SchemaId.Name}");
}

2
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -84,7 +84,7 @@ namespace Squidex.Extensions.Actions.Notification
command.AppId = NoApp;
command.FromRule = true;
await commandBus.PublishAsync(command);
await commandBus.PublishAsync(command, ct);
return Result.Success($"Notified: {job.Text}");
}

5
backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSource.cs

@ -47,7 +47,8 @@ namespace Squidex.Extensions.Assets.Azure
this.log = log;
}
public async Task EnhanceAsync(UploadAssetCommand command)
public async Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{
try
{
@ -55,7 +56,7 @@ namespace Squidex.Extensions.Assets.Azure
{
await using (var stream = command.File.OpenRead())
{
var result = await client.AnalyzeImageInStreamAsync(stream, features);
var result = await client.AnalyzeImageInStreamAsync(stream, features, cancellationToken: ct);
command.Tags ??= new HashSet<string>();

23
backend/extensions/Squidex.Extensions/Samples/Middleware/DoubleLinkedContentMiddleware.cs

@ -23,9 +23,10 @@ namespace Squidex.Extensions.Samples.Middleware
this.contentLoader = contentLoader;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
await next(context);
await next(context, ct);
if (context.Command is UpdateContent update && context.IsCompleted && update.SchemaId.Name == "source")
{
@ -36,7 +37,8 @@ namespace Squidex.Extensions.Samples.Middleware
await contentLoader.GetAsync(
content.AppId.Id,
content.Id,
content.Version - 1);
content.Version - 1,
ct);
// The data might have been changed within the domain object. Therefore we do not use the data fro mthe command.
var oldReferenceId = GetReference(contentPrevious?.Data);
@ -50,7 +52,7 @@ namespace Squidex.Extensions.Samples.Middleware
if (oldReferenceId != null)
{
var oldReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(oldReferenceId));
var oldReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(oldReferenceId), ct: ct);
if (oldReferenced != null)
{
@ -59,13 +61,13 @@ namespace Squidex.Extensions.Samples.Middleware
// Remove the reference from the old referenced content.
data.Remove("referencing");
await UpdateReferencing(context, oldReferenced, data);
await UpdateReferencing(context, oldReferenced, data, ct);
}
}
if (newReferenceId != null)
{
var newReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(newReferenceId));
var newReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(newReferenceId), ct: ct);
if (newReferenced != null)
{
@ -77,14 +79,16 @@ namespace Squidex.Extensions.Samples.Middleware
["iv"] = JsonValue.Array(content.Id)
};
await UpdateReferencing(context, newReferenced, data);
await UpdateReferencing(context, newReferenced, data, ct);
}
}
}
}
private static async Task UpdateReferencing(CommandContext context, IContentEntity reference, ContentData data)
private static async Task UpdateReferencing(CommandContext context, IContentEntity reference, ContentData data,
CancellationToken ct)
{
// Also set the expected version, otherwise it will be overriden with the version from the request.
await context.CommandBus.PublishAsync(new UpdateContent
{
AppId = reference.AppId,
@ -93,9 +97,8 @@ namespace Squidex.Extensions.Samples.Middleware
DoNotScript = true,
DoNotValidate = true,
Data = data,
// Also set the expected version, otherwise it will be overriden with the version from the request.
ExpectedVersion = reference.Version
});
}, ct);
}
private static string GetReference(ContentData data)

3
backend/i18n/frontend_en.json

@ -582,11 +582,14 @@
"eventConsumers.position": "Position",
"eventConsumers.refreshTooltip": "Refresh event consumers",
"eventConsumers.reloaded": "Event Consumers reloaded.",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "Failed to reset event consumer. Please reload.",
"eventConsumers.resetTooltip": "Reset Event Consumer",
"eventConsumers.started": "Event consumer will be started soon",
"eventConsumers.startFailed": "Failed to start event consumer. Please reload.",
"eventConsumers.startTooltip": "Start Event Consumer",
"eventConsumers.stopFailed": "Failed to stop event consumer. Please reload.",
"eventConsumers.stopped": "Event consumer will be stopped soon",
"eventConsumers.stopTooltip": "Stop Event Consumer",
"features.loadFailed": "Failed to load features. Please reload.",
"history.loadFailed": "Failed to load history. Please reload.",

3
backend/i18n/frontend_it.json

@ -582,11 +582,14 @@
"eventConsumers.position": "Posizione",
"eventConsumers.refreshTooltip": "Aggiorna gli eventi degli utenti",
"eventConsumers.reloaded": "Eventi degli utenti aggiornati.",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "Non è stato possibile ripristinare gli eventi degli utenti. Per favore ricarica.",
"eventConsumers.resetTooltip": "Ripristina gli event degli utenti",
"eventConsumers.started": "Event consumer will be started soon",
"eventConsumers.startFailed": "Non è stato possibile far partire l'evento dell'utente. Per favore ricarica.",
"eventConsumers.startTooltip": "Inizia l'evento utente",
"eventConsumers.stopFailed": "Non è stato possibile fermare l'evento dell'utente. Per favore ricarica.",
"eventConsumers.stopped": "Event consumer will be stopped soon",
"eventConsumers.stopTooltip": "Interrompi l'evento dell'utente",
"features.loadFailed": "Non è stato possibile caricare le funzionalità. Per favore ricarica.",
"history.loadFailed": "Non è stato possibile caricare la cronologia. Per favore ricarica.",

3
backend/i18n/frontend_nl.json

@ -582,11 +582,14 @@
"eventConsumers.position": "Positie",
"eventConsumers.refreshTooltip": "Ververs evenementgebruikers",
"eventConsumers.reloaded": "Evenementconsumenten herladen.",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "Kan gebeurtenisgebruiker niet resetten. Laad opnieuw.",
"eventConsumers.resetTooltip": "Reset Event Consumer",
"eventConsumers.started": "Event consumer will be started soon",
"eventConsumers.startFailed": "Het starten van een evenementconsument is mislukt. Laad opnieuw.",
"eventConsumers.startTooltip": "Start Event Consumer",
"eventConsumers.stopFailed": "Kan de gebruiker van het evenement niet stoppen. Laad opnieuw.",
"eventConsumers.stopped": "Event consumer will be stopped soon",
"eventConsumers.stopTooltip": "Stop Event Consumer",
"features.loadFailed": "Laden van functies is mislukt. Laad opnieuw.",
"history.loadFailed": "Kan geschiedenis niet laden. Laad opnieuw.",

3
backend/i18n/frontend_zh.json

@ -582,11 +582,14 @@
"eventConsumers.position": "位置",
"eventConsumers.refreshTooltip": "刷新事件消费者",
"eventConsumers.reloaded": "事件消费者重新加载。",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "无法重置事件消费者。请重新加载。",
"eventConsumers.resetTooltip": "重置事件消费者",
"eventConsumers.started": "Event consumer will be started soon",
"eventConsumers.startFailed": "无法启动事件消费者。请重新加载。",
"eventConsumers.startTooltip": "启动事件消费者",
"eventConsumers.stopFailed": "无法停止事件消费者。请重新加载。",
"eventConsumers.stopped": "Event consumer will be stopped soon",
"eventConsumers.stopTooltip": "停止事件消费者",
"features.loadFailed": "加载功能失败。请重新加载。",
"history.loadFailed": "加载历史记录失败。请重新加载。",

3
backend/i18n/source/frontend_en.json

@ -582,11 +582,14 @@
"eventConsumers.position": "Position",
"eventConsumers.refreshTooltip": "Refresh event consumers",
"eventConsumers.reloaded": "Event Consumers reloaded.",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "Failed to reset event consumer. Please reload.",
"eventConsumers.resetTooltip": "Reset Event Consumer",
"eventConsumers.started": "Event consumer will be started soon",
"eventConsumers.startFailed": "Failed to start event consumer. Please reload.",
"eventConsumers.startTooltip": "Start Event Consumer",
"eventConsumers.stopFailed": "Failed to stop event consumer. Please reload.",
"eventConsumers.stopped": "Event consumer will be stopped soon",
"eventConsumers.stopTooltip": "Stop Event Consumer",
"features.loadFailed": "Failed to load features. Please reload.",
"history.loadFailed": "Failed to load history. Please reload.",

4
backend/src/Migrations/MigrationPath.cs

@ -37,8 +37,6 @@ namespace Migrations
private IEnumerable<IMigration?> ResolveMigrators(int version)
{
yield return serviceProvider.GetRequiredService<StopEventConsumers>();
// Version 06: Convert Event store. Must always be executed first.
if (version < 6)
{
@ -121,8 +119,6 @@ namespace Migrations
{
yield return serviceProvider.GetRequiredService<ConvertRuleEventsJson>();
}
yield return serviceProvider.GetRequiredService<StartEventConsumers>();
}
}
}

30
backend/src/Migrations/Migrations/StartEventConsumers.cs

@ -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();
}
}
}

30
backend/src/Migrations/Migrations/StopEventConsumers.cs

@ -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();
}
}
}

8
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ExcludeChangedTypes.cs

@ -15,11 +15,11 @@ namespace Squidex.Domain.Apps.Core.ConvertContent
{
public sealed class ExcludeChangedTypes : IContentFieldConverter, IContentValueConverter
{
private readonly IJsonSerializer jsonSerializer;
private readonly IJsonSerializer serializer;
public ExcludeChangedTypes(IJsonSerializer jsonSerializer)
public ExcludeChangedTypes(IJsonSerializer serializer)
{
this.jsonSerializer = jsonSerializer;
this.serializer = serializer;
}
public ContentFieldData? ConvertField(IRootField field, ContentFieldData source)
@ -54,7 +54,7 @@ namespace Squidex.Domain.Apps.Core.ConvertContent
{
try
{
return !JsonValueValidator.IsValid(field, source, jsonSerializer);
return !JsonValueValidator.IsValid(field, source, serializer);
}
catch
{

10
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs

@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
private const string GlobalFallback = "null";
private static readonly Regex RegexPatternOld = new Regex(@"^(?<FullPath>(?<Type>[^_]*)_(?<Path>[^\s]*))", RegexOptions.Compiled | RegexOptions.ExplicitCapture);
private static readonly Regex RegexPatternNew = new Regex(@"^\{(?<FullPath>(?<Type>[\w]+)_(?<Path>[\w\.\-]+))[\s]*(\|[\s]*(?<Transform>[^\?}]+))?(\?[\s]*(?<Fallback>[^\}\s]+))?[\s]*\}", RegexOptions.Compiled | RegexOptions.ExplicitCapture);
private readonly IJsonSerializer jsonSerializer;
private readonly IJsonSerializer serializer;
private readonly IEnumerable<IRuleEventFormatter> formatters;
private readonly ITemplateEngine templateEngine;
private readonly IScriptEngine scriptEngine;
@ -69,9 +69,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules
}
}
public RuleEventFormatter(IJsonSerializer jsonSerializer, IEnumerable<IRuleEventFormatter> formatters, ITemplateEngine templateEngine, IScriptEngine scriptEngine)
public RuleEventFormatter(IJsonSerializer serializer, IEnumerable<IRuleEventFormatter> formatters, ITemplateEngine templateEngine, IScriptEngine scriptEngine)
{
this.jsonSerializer = jsonSerializer;
this.serializer = serializer;
this.formatters = formatters;
this.templateEngine = templateEngine;
this.scriptEngine = scriptEngine;
@ -81,14 +81,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
var payload = @event;
return jsonSerializer.Serialize(payload);
return serializer.Serialize(payload);
}
public virtual string ToEnvelope(EnrichedEvent @event)
{
var payload = new { type = @event.Name, payload = @event, timestamp = @event.Timestamp };
return jsonSerializer.Serialize(payload);
return serializer.Serialize(payload);
}
public async ValueTask<string?> FormatAsync(string text, EnrichedEvent @event)

25
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs

@ -27,31 +27,26 @@ namespace Squidex.Domain.Apps.Core.HandleRules
private readonly TypeNameRegistry typeNameRegistry;
private readonly RuleOptions ruleOptions;
private readonly IEventEnricher eventEnricher;
private readonly IJsonSerializer jsonSerializer;
private readonly IClock clock;
private readonly IJsonSerializer serializer;
private readonly ILogger<RuleService> log;
public IClock Clock { get; set; } = SystemClock.Instance;
public RuleService(
IOptions<RuleOptions> ruleOptions,
IEnumerable<IRuleTriggerHandler> ruleTriggerHandlers,
IEnumerable<IRuleActionHandler> ruleActionHandlers,
IEventEnricher eventEnricher,
IJsonSerializer jsonSerializer,
IClock clock,
IJsonSerializer serializer,
ILogger<RuleService> log,
TypeNameRegistry typeNameRegistry)
{
this.typeNameRegistry = typeNameRegistry;
this.eventEnricher = eventEnricher;
this.ruleOptions = ruleOptions.Value;
this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType);
this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType);
this.eventEnricher = eventEnricher;
this.jsonSerializer = jsonSerializer;
this.clock = clock;
this.serializer = serializer;
this.log = log;
}
@ -94,7 +89,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
yield break;
}
var now = clock.GetCurrentInstant();
var now = Clock.GetCurrentInstant();
await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct))
{
@ -204,7 +199,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return;
}
var now = clock.GetCurrentInstant();
var now = Clock.GetCurrentInstant();
var eventTime =
@event.Headers.ContainsKey(CommonHeaders.Timestamp) ?
@ -321,7 +316,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action);
var json = jsonSerializer.Serialize(data);
var json = serializer.Serialize(data);
job.ActionData = json;
job.ActionName = actionName;
@ -367,7 +362,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var actionType = typeNameRegistry.GetType(actionName);
var actionHandler = ruleActionHandlers[actionType];
var deserialized = jsonSerializer.Deserialize<object>(job, actionHandler.DataType);
var deserialized = serializer.Deserialize<object>(job, actionHandler.DataType);
using (var cts = new CancellationTokenSource(GetTimeoutInMs()))
{

24
backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs

@ -11,20 +11,28 @@ namespace Squidex.Domain.Apps.Core.Tags
{
public interface ITagService
{
Task<Dictionary<string, string>> GetTagIdsAsync(DomainId appId, string group, HashSet<string> names);
Task<Dictionary<string, string>> GetTagIdsAsync(DomainId id, string group, HashSet<string> names,
CancellationToken ct = default);
Task<Dictionary<string, string>> NormalizeTagsAsync(DomainId appId, string group, HashSet<string>? names, HashSet<string>? ids);
Task<Dictionary<string, string>> NormalizeTagsAsync(DomainId id, string group, HashSet<string>? names, HashSet<string>? ids,
CancellationToken ct = default);
Task<Dictionary<string, string>> DenormalizeTagsAsync(DomainId appId, string group, HashSet<string> ids);
Task<Dictionary<string, string>> DenormalizeTagsAsync(DomainId id, string group, HashSet<string> ids,
CancellationToken ct = default);
Task<TagsSet> GetTagsAsync(DomainId appId, string group);
Task<TagsSet> GetTagsAsync(DomainId id, string group,
CancellationToken ct = default);
Task<TagsExport> GetExportableTagsAsync(DomainId appId, string group);
Task<TagsExport> GetExportableTagsAsync(DomainId id, string group,
CancellationToken ct = default);
Task RenameTagAsync(DomainId appId, string group, string name, string newName);
Task RenameTagAsync(DomainId id, string group, string name, string newName,
CancellationToken ct = default);
Task RebuildTagsAsync(DomainId appId, string group, TagsExport export);
Task RebuildTagsAsync(DomainId id, string group, TagsExport export,
CancellationToken ct = default);
Task ClearAsync(DomainId appId, string group);
Task ClearAsync(DomainId id, string group,
CancellationToken ct = default);
}
}

8
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs

@ -22,19 +22,19 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
{
private static readonly JsonValueConverter Instance = new JsonValueConverter();
public record struct Args(JsonValue Value, IJsonSerializer JsonSerializer, ResolvedComponents Components);
public record struct Args(JsonValue Value, IJsonSerializer Serializer, ResolvedComponents Components);
private JsonValueConverter()
{
}
public static (object? Result, JsonError? Error) ConvertValue(IField field, JsonValue value, IJsonSerializer jsonSerializer,
public static (object? Result, JsonError? Error) ConvertValue(IField field, JsonValue value, IJsonSerializer serializer,
ResolvedComponents components)
{
Guard.NotNull(field);
Guard.NotNull(value);
var args = new Args(value, jsonSerializer, components);
var args = new Args(value, serializer, components);
return field.Accept(Instance, args);
}
@ -128,7 +128,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
public (object? Result, JsonError? Error) Visit(IField<GeolocationFieldProperties> field, Args args)
{
var result = GeoJsonValue.TryParse(args.Value, args.JsonSerializer, out var value);
var result = GeoJsonValue.TryParse(args.Value, args.Serializer, out var value);
switch (result)
{

8
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueValidator.cs

@ -20,18 +20,18 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
{
private static readonly JsonValueValidator Instance = new JsonValueValidator();
public record struct Args(JsonValue Value, IJsonSerializer JsonSerializer);
public record struct Args(JsonValue Value, IJsonSerializer Serializer);
private JsonValueValidator()
{
}
public static bool IsValid(IField field, JsonValue value, IJsonSerializer jsonSerializer)
public static bool IsValid(IField field, JsonValue value, IJsonSerializer serializer)
{
Guard.NotNull(field);
Guard.NotNull(value);
var args = new Args(value, jsonSerializer);
var args = new Args(value, serializer);
return field.Accept(Instance, args);
}
@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
public bool Visit(IField<GeolocationFieldProperties> field, Args args)
{
var result = GeoJsonValue.TryParse(args.Value, args.JsonSerializer, out _);
var result = GeoJsonValue.TryParse(args.Value, args.Serializer, out _);
return result == GeoJsonParseResult.Success;
}

6
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/RootContext.cs

@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
private readonly ConcurrentBag<ValidationError> errors = new ConcurrentBag<ValidationError>();
private readonly Scheduler scheduler = new Scheduler();
public IJsonSerializer JsonSerializer { get; }
public IJsonSerializer Serializer { get; }
public DomainId ContentId { get; }
@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
}
public RootContext(
IJsonSerializer jsonSerializer,
IJsonSerializer serializer,
NamedId<DomainId> appId,
NamedId<DomainId> schemaId,
Schema schema,
@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
AppId = appId;
Components = components;
ContentId = contentId;
JsonSerializer = jsonSerializer;
Serializer = serializer;
Schema = schema;
SchemaId = schemaId;
}

2
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs

@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
typedValue = jsonValue.Value;
var (json, error) = JsonValueConverter.ConvertValue(field, jsonValue,
context.Root.JsonSerializer,
context.Root.Serializer,
context.Root.Components);
if (error != null)

43
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs

@ -18,8 +18,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
{
public sealed class MongoAppRepository : MongoSnapshotStoreBase<AppDomainObject.State, MongoAppEntity>, IAppRepository, IDeleter
{
public MongoAppRepository(IMongoDatabase database, JsonSerializer jsonSerializer)
: base(database, jsonSerializer)
public MongoAppRepository(IMongoDatabase database, JsonSerializer serializer)
: base(database, serializer)
{
}
@ -82,5 +82,44 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
return result;
}
public async Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAllAsync"))
{
var entities =
await Collection.Find(x => (x.IndexedUserIds.Contains(contributorId) || names.Contains(x.IndexedName)) && !x.IndexedDeleted)
.ToListAsync(ct);
return entities.Select(x => (IAppEntity)x.Document).ToList();
}
}
public async Task<IAppEntity?> FindAsync(DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoAppRepository/FindAsync"))
{
var entity =
await Collection.Find(x => x.DocumentId == id && !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
public async Task<IAppEntity?> FindAsync(string name,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoAppRepository/FindAsyncByName"))
{
var entity =
await Collection.Find(x => x.IndexedName == name && !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
}
}

16
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository.cs

@ -8,18 +8,18 @@
using MongoDB.Driver;
using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.DomainObject;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
{
public sealed class MongoRuleRepository : MongoSnapshotStoreBase<RuleDomainObject.State, MongoRuleEntity>, IRuleRepository, IDeleter
{
public MongoRuleRepository(IMongoDatabase database, JsonSerializer jsonSerializer)
: base(database, jsonSerializer)
public MongoRuleRepository(IMongoDatabase database, JsonSerializer serializer)
: base(database, serializer)
{
}
@ -40,14 +40,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct);
}
public async Task<List<DomainId>> QueryIdsAsync(DomainId appId,
public async Task<List<IRuleEntity>> QueryAllAsync(DomainId appId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoRuleRepository/QueryIdsAsync"))
using (Telemetry.Activities.StartActivity("MongoRuleRepository/QueryAllAsync"))
{
var entities = await Collection.Find(x => x.IndexedAppId == appId && !x.IndexedDeleted).Only(x => x.IndexedId).ToListAsync(ct);
var entities =
await Collection.Find(x => x.IndexedAppId == appId && !x.IndexedDeleted)
.ToListAsync(ct);
return entities.Select(x => DomainId.Create(x["_ri"].AsString)).ToList();
return entities.Select(x => (IRuleEntity)x.Document).ToList();
}
}
}

45
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository.cs

@ -8,18 +8,18 @@
using MongoDB.Driver;
using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.DomainObject;
using Squidex.Domain.Apps.Entities.Schemas.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas
{
public sealed class MongoSchemaRepository : MongoSnapshotStoreBase<SchemaDomainObject.State, MongoSchemaEntity>, ISchemaRepository, IDeleter
{
public MongoSchemaRepository(IMongoDatabase database, JsonSerializer jsonSerializer)
: base(database, jsonSerializer)
public MongoSchemaRepository(IMongoDatabase database, JsonSerializer serializer)
: base(database, serializer)
{
}
@ -41,33 +41,42 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas
return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct);
}
public async Task<Dictionary<string, DomainId>> QueryIdsAsync(DomainId appId,
CancellationToken ct = default)
public async Task<List<ISchemaEntity>> QueryAllAsync(DomainId appId, CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoSchemaRepository/QueryAsync"))
using (Telemetry.Activities.StartActivity("MongoSchemaRepository/QueryAllAsync"))
{
var find = Collection.Find(x => x.IndexedAppId == appId && !x.IndexedDeleted);
var entities =
await Collection.Find(x => x.IndexedAppId == appId && !x.IndexedDeleted)
.ToListAsync(ct);
return await QueryAsync(find, ct);
return entities.Select(x => (ISchemaEntity)x.Document).ToList();
}
}
private static async Task<Dictionary<string, DomainId>> QueryAsync(IFindFluent<MongoSchemaEntity, MongoSchemaEntity> find,
CancellationToken ct)
public async Task<ISchemaEntity?> FindAsync(DomainId appId, DomainId id,
CancellationToken ct = default)
{
var entities = await find.SortBy(x => x.IndexedCreated).Only(x => x.IndexedId, x => x.IndexedName).ToListAsync(ct);
using (Telemetry.Activities.StartActivity("MongoSchemaRepository/FindAsync"))
{
var entity =
await Collection.Find(x => x.IndexedAppId == appId && x.IndexedId == id && !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
var result = new Dictionary<string, DomainId>();
return entity?.Document;
}
}
foreach (var entity in entities)
public async Task<ISchemaEntity?> FindAsync(DomainId appId, string name,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoSchemaRepository/FindAsyncByName"))
{
var indexedId = DomainId.Create(entity["_si"].AsString);
var indexedName = entity["_sn"].AsString;
var entity =
await Collection.Find(x => x.IndexedAppId == appId && x.IndexedName == name && !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
result[indexedName] = indexedId;
return entity?.Document;
}
return result;
}
}
}

8
backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs

@ -14,9 +14,10 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AlwaysCreateClientCommandMiddleware : ICommandMiddleware
{
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
await next(context);
await next(context, ct);
if (context.IsCompleted && context.Command is CreateApp createApp)
{
@ -26,7 +27,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
command.AppId = appId;
var newContext = await context.CommandBus.PublishAsync(command);
// If we have the app already it is not worth to cancel the step here.
var newContext = await context.CommandBus.PublishAsync(command, default);
context.Complete(newContext.PlainResult);
});

15
backend/src/Squidex.Domain.Apps.Entities/Apps/AppPermanentDeleter.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Apps.DomainObject;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public sealed class AppPermanentDeleter : IEventConsumer
{
private readonly IEnumerable<IDeleter> deleters;
private readonly IGrainFactory grainFactory;
private readonly IDomainObjectFactory factory;
private readonly HashSet<string> consumingTypes;
public string Name
@ -30,11 +30,10 @@ namespace Squidex.Domain.Apps.Entities.Apps
get => "^app-";
}
public AppPermanentDeleter(IEnumerable<IDeleter> deleters, IGrainFactory grainFactory, TypeNameRegistry typeNameRegistry)
public AppPermanentDeleter(IEnumerable<IDeleter> deleters, IDomainObjectFactory factory, TypeNameRegistry typeNameRegistry)
{
this.deleters = deleters.OrderBy(x => x.Order).ToList();
this.grainFactory = grainFactory;
this.factory = factory;
// Compute the event types names once for performance reasons and use hashset for extensibility.
consumingTypes = new HashSet<string>
@ -88,9 +87,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
using (Telemetry.Activities.StartActivity("RemoveAppFromSystem"))
{
// Bypass our normal app resolve process, so that we can also retrieve the deleted app.
var appGrain = grainFactory.GetGrain<IAppGrain>(appArchived.AppId.Id.ToString());
var app = factory.Create<AppDomainObject>(appArchived.AppId.Id);
var app = await appGrain.GetStateAsync();
await app.EnsureLoadedAsync();
// If the app does not exist, the version is lower than zero.
if (app.Version < 0)
@ -102,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
using (Telemetry.Activities.StartActivity(deleter.GetType().Name))
{
await deleter.DeleteAppAsync(app, default);
await deleter.DeleteAppAsync(app.Snapshot, default);
}
}
}

142
backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs

@ -5,66 +5,170 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AppUISettings : IAppUISettings, IDeleter
{
private readonly IGrainFactory grainFactory;
private readonly IPersistenceFactory<State> persistanceFactory;
public AppUISettings(IGrainFactory grainFactory)
[CollectionName("UISettings")]
public sealed class State
{
this.grainFactory = grainFactory;
public JsonObject Settings { get; set; } = new JsonObject();
public void Set(JsonObject settings)
{
Settings = settings;
}
public bool Set(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 false;
}
if (!container.TryGetValue(key, out var existing) || !existing.Equals(value))
{
container[key] = value;
return true;
}
return false;
}
public bool Remove(string path)
{
var container = GetContainer(path, false, out var key);
if (container == null)
{
return false;
}
return container.Remove(key);
}
private JsonObject? GetContainer(string path, bool add, out string key)
{
Guard.NotNullOrEmpty(path);
var segments = path.Split('.');
key = segments[^1];
var current = 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;
}
}
public AppUISettings(IPersistenceFactory<State> persistanceFactory)
{
this.persistanceFactory = persistanceFactory;
}
async Task IDeleter.DeleteContributorAsync(DomainId appId, string contributorId,
CancellationToken ct)
{
await GetGrain(appId, null).ClearAsync();
await ClearAsync(appId, contributorId, ct);
}
async Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
await GetGrain(app.Id, null).ClearAsync();
await ClearAsync(app.Id, null, ct);
foreach (var userId in app.Contributors.Keys)
{
await GetGrain(app.Id, userId).ClearAsync();
await ClearAsync(app.Id, userId, ct);
}
}
public Task<JsonObject> GetAsync(DomainId appId, string? userId)
public async Task<JsonObject> GetAsync(DomainId appId, string? userId,
CancellationToken ct = default)
{
return GetGrain(appId, userId).GetAsync();
var state = await GetStateAsync(appId, userId, ct);
return state.Value.Settings;
}
public Task RemoveAsync(DomainId appId, string? userId, string path)
public async Task RemoveAsync(DomainId appId, string? userId, string path,
CancellationToken ct = default)
{
return GetGrain(appId, userId).RemoveAsync(path);
var state = await GetStateAsync(appId, userId, ct);
await state.UpdateIfAsync(s => s.Remove(path), ct: ct);
}
public Task SetAsync(DomainId appId, string? userId, string path, JsonValue value)
public async Task SetAsync(DomainId appId, string? userId, string path, JsonValue value,
CancellationToken ct = default)
{
return GetGrain(appId, userId).SetAsync(path, value);
var state = await GetStateAsync(appId, userId, ct);
await state.UpdateIfAsync(s => s.Set(path, value), ct: ct);
}
public Task SetAsync(DomainId appId, string? userId, JsonObject settings)
public async Task SetAsync(DomainId appId, string? userId, JsonObject settings,
CancellationToken ct = default)
{
return GetGrain(appId, userId).SetAsync(settings);
var state = await GetStateAsync(appId, userId, ct);
await state.UpdateAsync(s => s.Set(settings), ct: ct);
}
public Task ClearAsync(DomainId appId, string? userId)
public async Task ClearAsync(DomainId appId, string? userId,
CancellationToken ct = default)
{
return GetGrain(appId, userId).ClearAsync();
var state = await GetStateAsync(appId, userId, ct);
await state.ClearAsync(ct);
}
private IAppUISettingsGrain GetGrain(DomainId appId, string? userId)
private async Task<SimpleState<State>> GetStateAsync(DomainId appId, string? userId,
CancellationToken ct)
{
return grainFactory.GetGrain<IAppUISettingsGrain>(GetKey(appId, userId));
var state = new SimpleState<State>(persistanceFactory, GetType(), GetKey(appId, userId));
await state.LoadAsync(ct);
return state;
}
private static string GetKey(DomainId appId, string? userId)

120
backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs

@ -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;
}
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs

@ -59,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public async Task BackupAsync(BackupContext context,
CancellationToken ct)
{
var json = await appUISettings.GetAsync(context.AppId, null);
var json = await appUISettings.GetAsync(context.AppId, null, ct);
await context.Writer.WriteJsonAsync(SettingsFile, json, ct);
}
@ -116,7 +116,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
var json = await context.Reader.ReadJsonAsync<JsonObject>(SettingsFile, ct);
await appUISettings.SetAsync(context.AppId, null, json);
await appUISettings.SetAsync(context.AppId, null, json, ct);
}
private async Task ReserveAppAsync(DomainId appId, string appName,
@ -139,7 +139,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
await rebuilder.InsertManyAsync<AppDomainObject, AppDomainObject.State>(Enumerable.Repeat(context.AppId, 1), 1, default);
await appsIndex.RegisterAsync(context.AppId, appName);
await appsIndex.RemoveReservationAsync(appReservation);
}

37
backend/src/Squidex.Domain.Apps.Entities/Apps/Diagnostics/OrleansAppsHealthCheck.cs

@ -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);
}
}
}

31
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Assets;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Commands;
@ -14,51 +13,51 @@ using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{
public sealed class AppCommandMiddleware : GrainCommandMiddleware<AppCommand, IAppGrain>
public sealed class AppCommandMiddleware : AggregateCommandMiddleware<AppCommand, AppDomainObject>
{
private readonly IAppImageStore appImageStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IContextProvider contextProvider;
public AppCommandMiddleware(
IGrainFactory grainFactory,
IAppImageStore appImageStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IContextProvider contextProvider)
: base(grainFactory)
public AppCommandMiddleware(IDomainObjectFactory domainObjectFactory,
IAppImageStore appImageStore, IAssetThumbnailGenerator assetThumbnailGenerator, IContextProvider contextProvider)
: base(domainObjectFactory)
{
this.appImageStore = appImageStore;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.contextProvider = contextProvider;
}
public override async Task HandleAsync(CommandContext context, NextDelegate next)
public override async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is UploadAppImage uploadImage)
{
await UploadAsync(uploadImage);
await UploadAsync(uploadImage, ct);
}
await base.HandleAsync(context, next);
await base.HandleAsync(context, next, ct);
}
protected override Task<object> EnrichResultAsync(CommandContext context, CommandResult result)
protected override Task<object> EnrichResultAsync(CommandContext context, CommandResult result,
CancellationToken ct)
{
if (result.Payload is IAppEntity app)
{
contextProvider.Context.App = app;
}
return base.EnrichResultAsync(context, result);
return base.EnrichResultAsync(context, result, ct);
}
private async Task UploadAsync(UploadAppImage uploadImage)
private async Task UploadAsync(UploadAppImage uploadImage,
CancellationToken ct)
{
var file = uploadImage.File;
await using (var uploadStream = file.OpenRead())
{
var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, file.MimeType);
var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, file.MimeType, ct);
if (image == null)
{
@ -68,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
await using (var uploadStream = file.OpenRead())
{
await appImageStore.UploadAsync(uploadImage.AppId.Id, uploadStream);
await appImageStore.UploadAsync(uploadImage.AppId.Id, uploadStream, ct);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs

@ -18,7 +18,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{
public sealed partial class AppDomainObject
public partial class AppDomainObject
{
[CollectionName("Apps")]
public sealed class State : DomainObjectState<State>, IAppEntity

55
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs

@ -24,7 +24,7 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{
public sealed partial class AppDomainObject : DomainObject<AppDomainObject.State>
public partial class AppDomainObject : DomainObject<AppDomainObject.State>
{
private readonly IServiceProvider serviceProvider;
@ -50,7 +50,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return command is AppUpdateCommand update && Equals(update?.AppId?.Id, Snapshot.Id);
}
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command)
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{
switch (command)
{
@ -62,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Create(c);
return Snapshot;
});
}, ct);
case UpdateApp update:
return UpdateReturn(update, c =>
@ -72,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Update(c);
return Snapshot;
});
}, ct);
case UpdateAppSettings updateSettings:
return UpdateReturn(updateSettings, c =>
@ -82,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateSettings(c);
return Snapshot;
});
}, ct);
case UploadAppImage uploadImage:
return UpdateReturn(uploadImage, c =>
@ -92,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UploadImage(c);
return Snapshot;
});
}, ct);
case RemoveAppImage removeImage:
return UpdateReturn(removeImage, c =>
@ -102,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RemoveImage(c);
return Snapshot;
});
}, ct);
case ConfigureAssetScripts configureAssetScripts:
return UpdateReturn(configureAssetScripts, c =>
@ -112,17 +113,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
ConfigureAssetScripts(c);
return Snapshot;
});
}, ct);
case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async c =>
return UpdateReturnAsync(assignContributor, async (c, ct) =>
{
await GuardAppContributors.CanAssign(c, Snapshot, UserResolver(), GetPlan());
AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
return Snapshot;
});
}, ct);
case RemoveContributor removeContributor:
return UpdateReturn(removeContributor, c =>
@ -132,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RemoveContributor(c);
return Snapshot;
});
}, ct);
case AttachClient attachClient:
return UpdateReturn(attachClient, c =>
@ -142,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AttachClient(c);
return Snapshot;
});
}, ct);
case UpdateClient updateClient:
return UpdateReturn(updateClient, c =>
@ -152,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateClient(c);
return Snapshot;
});
}, ct);
case RevokeClient revokeClient:
return UpdateReturn(revokeClient, c =>
@ -162,7 +163,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RevokeClient(c);
return Snapshot;
});
}, ct);
case AddWorkflow addWorkflow:
return UpdateReturn(addWorkflow, c =>
@ -172,7 +173,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AddWorkflow(c);
return Snapshot;
});
}, ct);
case UpdateWorkflow updateWorkflow:
return UpdateReturn(updateWorkflow, c =>
@ -182,7 +183,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateWorkflow(c);
return Snapshot;
});
}, ct);
case DeleteWorkflow deleteWorkflow:
return UpdateReturn(deleteWorkflow, c =>
@ -192,7 +193,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
DeleteWorkflow(c);
return Snapshot;
});
}, ct);
case AddLanguage addLanguage:
return UpdateReturn(addLanguage, c =>
@ -202,7 +203,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AddLanguage(c);
return Snapshot;
});
}, ct);
case RemoveLanguage removeLanguage:
return UpdateReturn(removeLanguage, c =>
@ -212,7 +213,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RemoveLanguage(c);
return Snapshot;
});
}, ct);
case UpdateLanguage updateLanguage:
return UpdateReturn(updateLanguage, c =>
@ -222,7 +223,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateLanguage(c);
return Snapshot;
});
}, ct);
case AddRole addRole:
return UpdateReturn(addRole, c =>
@ -232,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AddRole(c);
return Snapshot;
});
}, ct);
case DeleteRole deleteRole:
return UpdateReturn(deleteRole, c =>
@ -242,7 +243,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
DeleteRole(c);
return Snapshot;
});
}, ct);
case UpdateRole updateRole:
return UpdateReturn(updateRole, c =>
@ -252,10 +253,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateRole(c);
return Snapshot;
});
}, ct);
case ChangePlan changePlan:
return UpdateReturnAsync(changePlan, async c =>
return UpdateReturnAsync(changePlan, async (c, ct) =>
{
GuardApp.CanChangePlan(c, Snapshot, AppPlansProvider());
@ -278,15 +279,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return result;
}
});
}, ct);
case DeleteApp delete:
return UpdateAsync(delete, async c =>
return UpdateAsync(delete, async (c, ct) =>
{
await AppPlanBillingManager().ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null, null);
DeleteApp(c);
});
}, ct);
default:
ThrowHelper.NotSupportedException();

27
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObjectGrain.cs

@ -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;
}
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/IAppGrain.cs

@ -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();
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs

@ -12,12 +12,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
public interface IAppUISettings
{
Task<JsonObject> GetAsync(DomainId appId, string? userId);
Task<JsonObject> GetAsync(DomainId appId, string? userId,
CancellationToken ct = default);
Task SetAsync(DomainId appId, string? userId, string path, JsonValue value);
Task SetAsync(DomainId appId, string? userId, string path, JsonValue value,
CancellationToken ct = default);
Task SetAsync(DomainId appId, string? userId, JsonObject settings);
Task SetAsync(DomainId appId, string? userId, JsonObject settings,
CancellationToken ct = default);
Task RemoveAsync(DomainId appId, string? userId, string path);
Task RemoveAsync(DomainId appId, string? userId, string path,
CancellationToken ct = default);
}
}

108
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsCacheGrain.cs

@ -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;
}
}
}

154
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs

@ -5,51 +5,57 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Caching;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.DomainObject;
using Squidex.Domain.Apps.Entities.Apps.Repositories;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{
public sealed class AppsIndex : IAppsIndex, ICommandMiddleware
public sealed class AppsIndex : IAppsIndex, ICommandMiddleware, IInitializable
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IAppRepository appRepository;
private readonly IGrainFactory grainFactory;
private readonly IReplicatedCache grainCache;
private readonly IReplicatedCache appCache;
private readonly NameReservationState namesState;
public AppsIndex(IAppRepository appRepository, IGrainFactory grainFactory, IReplicatedCache grainCache)
public AppsIndex(IAppRepository appRepository, IReplicatedCache appCache,
IPersistenceFactory<NameReservationState.State> persistenceFactory)
{
this.appRepository = appRepository;
this.grainFactory = grainFactory;
this.grainCache = grainCache;
this.appCache = appCache;
namesState = new NameReservationState(persistenceFactory, "Apps");
}
public Task RegisterAsync(DomainId id, string name,
CancellationToken ct = default)
public Task InitializeAsync(
CancellationToken ct)
{
return Cache().AddAsync(id, name);
return namesState.LoadAsync(ct);
}
public Task RemoveReservationAsync(string? token,
CancellationToken ct = default)
{
return Cache().RemoveReservationAsync(token);
return namesState.RemoveReservationAsync(token, ct);
}
public async Task<string?> ReserveAsync(DomainId id, string name,
CancellationToken ct = default)
{
return await Cache().ReserveAsync(id, name);
if (await appRepository.FindAsync(name, ct) != null)
{
return null;
}
return await namesState.ReserveAsync(id, name, ct);
}
public async Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions,
@ -57,17 +63,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{
using (Telemetry.Activities.StartActivity("AppsIndex/GetAppsForUserAsync"))
{
var ids =
await Task.WhenAll(
GetAppIdsByUserAsync(userId),
GetAppIdsAsync(permissions.ToAppNames()));
var apps = await appRepository.QueryAllAsync(userId, permissions.ToAppNames(), ct);
var apps =
await Task.WhenAll(ids
.SelectMany(x => x).Distinct()
.Select(id => GetAppAsync(id, false, ct)));
foreach (var app in apps.Where(IsValid))
{
await CacheItAsync(app);
}
return apps.NotNull().ToList();
return apps.Where(IsValid).ToList();
}
}
@ -78,20 +81,25 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{
if (canCache)
{
if (grainCache.TryGetValue(GetCacheKey(name), out var v) && v is IAppEntity cacheApp)
if (appCache.TryGetValue(GetCacheKey(name), out var v) && v is IAppEntity cacheApp)
{
return cacheApp;
}
}
var appId = await GetAppIdAsync(name);
var app = await appRepository.FindAsync(name, ct);
if (appId == DomainId.Empty)
if (!IsValid(app))
{
return null;
app = null;
}
if (app != null)
{
await CacheItAsync(app);
}
return await GetAppAsync(appId, canCache, ct);
return app;
}
}
@ -102,13 +110,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{
if (canCache)
{
if (grainCache.TryGetValue(GetCacheKey(appId), out var cached) && cached is IAppEntity cachedApp)
if (appCache.TryGetValue(GetCacheKey(appId), out var cached) && cached is IAppEntity cachedApp)
{
return cachedApp;
}
}
var app = await GetAppCoreAsync(appId);
var app = await appRepository.FindAsync(appId, ct);
if (!IsValid(app))
{
app = null;
}
if (app != null)
{
@ -119,57 +132,27 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
}
}
private async Task<IReadOnlyCollection<DomainId>> GetAppIdsByUserAsync(string userId)
{
using (Telemetry.Activities.StartActivity("AppsIndex/GetAppIdsByUserAsync"))
{
var result = await appRepository.QueryIdsAsync(userId);
return result.Values;
}
}
private async Task<IReadOnlyCollection<DomainId>> GetAppIdsAsync(string[] names)
{
using (Telemetry.Activities.StartActivity("AppsIndex/GetAppIdsAsync"))
{
var result = await Cache().GetAppIdsAsync(names);
return result;
}
}
private async Task<DomainId> GetAppIdAsync(string name)
{
using (Telemetry.Activities.StartActivity("AppsIndex/GetAppIdAsync"))
{
var result = await Cache().GetAppIdsAsync(new[] { name });
return result.FirstOrDefault();
}
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
var command = context.Command;
if (command is CreateApp createApp)
{
var cache = Cache();
var token = await CheckAppAsync(cache, createApp);
var token = await CheckAppAsync(createApp, ct);
try
{
await next(context);
await next(context, ct);
}
finally
{
await cache.RemoveReservationAsync(token);
// Always remove the reservation and therefore do not pass over cancellation token.
await namesState.RemoveReservationAsync(token, default);
}
}
else
{
await next(context);
await next(context, ct);
}
if (context.IsCompleted)
@ -189,9 +172,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
}
}
private static async Task<string?> CheckAppAsync(IAppsCacheGrain cache, CreateApp command)
private async Task<string?> CheckAppAsync(CreateApp command,
CancellationToken ct)
{
var token = await cache.ReserveAsync(command.AppId, command.Name);
var token = await ReserveAsync(command.AppId, command.Name, ct);
if (token == null)
{
@ -204,15 +188,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
private async Task OnCreateAsync(CreateApp create)
{
await InvalidateItAsync(create.AppId, create.Name);
await Cache().AddAsync(create.AppId, create.Name);
}
private async Task OnDeleteAsync(DeleteApp delete)
{
await InvalidateItAsync(delete.AppId.Id, delete.AppId.Name);
await Cache().RemoveAsync(delete.AppId.Id);
}
private async Task OnUpdateAsync(AppUpdateCommand update)
@ -220,23 +200,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
await InvalidateItAsync(update.AppId.Id, update.AppId.Name);
}
private IAppsCacheGrain Cache()
{
return grainFactory.GetGrain<IAppsCacheGrain>(SingleGrain.Id);
}
private async Task<IAppEntity?> GetAppCoreAsync(DomainId id, bool allowArchived = false)
{
var app = await grainFactory.GetGrain<IAppGrain>(id.ToString()).GetStateAsync();
if (app.Version <= EtagVersion.Empty || (app.IsDeleted && !allowArchived))
{
return null;
}
return app;
}
private static string GetCacheKey(DomainId id)
{
return $"{typeof(AppsIndex)}_Apps_Id_{id}";
@ -247,9 +210,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
return $"{typeof(AppsIndex)}_Apps_Name_{name}";
}
private static bool IsValid(IAppEntity? app)
{
return app != null && app.Version > EtagVersion.Empty && !app.IsDeleted;
}
private Task InvalidateItAsync(DomainId id, string name)
{
return grainCache.RemoveAsync(
return appCache.RemoveAsync(
GetCacheKey(id),
GetCacheKey(name));
}
@ -257,8 +225,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
private Task CacheItAsync(IAppEntity app)
{
return Task.WhenAll(
grainCache.AddAsync(GetCacheKey(app.Id), app, CacheDuration),
grainCache.AddAsync(GetCacheKey(app.Name), app, CacheDuration));
appCache.AddAsync(GetCacheKey(app.Id), app, CacheDuration),
appCache.AddAsync(GetCacheKey(app.Name), app, CacheDuration));
}
}
}

21
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsCacheGrain.cs

@ -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);
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs

@ -24,9 +24,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
Task<string?> ReserveAsync(DomainId id, string name,
CancellationToken ct = default);
Task RegisterAsync(DomainId id, string name,
CancellationToken ct = default);
Task RemoveReservationAsync(string? token,
CancellationToken ct = default);
}

11
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -21,7 +21,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
this.userResolver = userResolver;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is AssignContributor assignContributor && ShouldResolve(assignContributor))
{
@ -31,11 +32,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
if (assignContributor.Invite)
{
(user, created) = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true);
(user, created) = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true, ct);
}
else
{
user = await userResolver.FindByIdOrEmailAsync(assignContributor.ContributorId);
user = await userResolver.FindByIdOrEmailAsync(assignContributor.ContributorId, ct);
}
if (user != null)
@ -43,7 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
assignContributor.ContributorId = user.Id;
}
await next(context);
await next(context, ct);
if (created && context.PlainResult is IAppEntity app)
{
@ -52,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
}
else
{
await next(context);
await next(context, ct);
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlanBillingManager.cs

@ -13,8 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
bool HasPortal { get; }
Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<DomainId> appId, string? planId, string? referer);
Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<DomainId> appId, string? planId, string? referer,
CancellationToken ct = default);
Task<string> GetPortalLinkAsync(string userId);
Task<string> GetPortalLinkAsync(string userId,
CancellationToken ct = default);
}
}

18
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IUsageNotifierGrain.cs

@ -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);
}
}

14
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotification.cs → backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/Messages.cs

@ -7,18 +7,20 @@
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public sealed class UsageNotification
public sealed record UsageTrackingCheck
{
public DomainId AppId { get; set; }
public DomainId AppId { get; init; }
public string AppName { get; set; }
public string AppName { get; init; }
public long Usage { get; set; }
public long Usage { get; init; }
public long UsageLimit { get; set; }
public long UsageLimit { get; init; }
public string[] Users { get; set; }
public string[] Users { get; init; }
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/NoopAppPlanBillingManager.cs

@ -16,12 +16,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
get => false;
}
public Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<DomainId> appId, string? planId, string? referer)
public Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<DomainId> appId, string? planId, string? referer,
CancellationToken ct = default)
{
return Task.FromResult<IChangePlanResult>(new PlanChangedResult());
}
public Task<string> GetPortalLinkAsync(string userId)
public Task<string> GetPortalLinkAsync(string userId,
CancellationToken ct = default)
{
return Task.FromResult(string.Empty);
}

12
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs

@ -27,17 +27,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
this.userResolver = userResolver;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (usageOptions.MaximumNumberOfApps <= 0 || context.Command is not CreateApp createApp || createApp.Actor.IsClient)
{
await next(context);
await next(context, ct);
return;
}
var totalApps = 0;
var user = await userResolver.FindByIdAsync(createApp.Actor.Identifier);
var user = await userResolver.FindByIdAsync(createApp.Actor.Identifier, ct);
if (user != null)
{
@ -49,14 +50,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
}
}
await next(context);
await next(context, ct);
if (context.IsCompleted && user != null)
{
var newApps = totalApps + 1;
var newAppsValue = newApps.ToString(CultureInfo.InvariantCulture);
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.TotalApps, newAppsValue, true);
// Always update the user and therefore do nto pass cancellation token.
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.TotalApps, newAppsValue, true, default);
}
}
}

57
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs

@ -7,12 +7,10 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Orleans;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
@ -21,63 +19,60 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IAppPlansProvider appPlansProvider;
private readonly IApiUsageTracker apiUsageTracker;
private readonly IGrainFactory grainFactory;
private readonly IMessageBus messaging;
public UsageGate(IAppPlansProvider appPlansProvider, IApiUsageTracker apiUsageTracker, IGrainFactory grainFactory)
public UsageGate(IAppPlansProvider appPlansProvider, IApiUsageTracker apiUsageTracker, IMessageBus messaging)
{
this.appPlansProvider = appPlansProvider;
this.apiUsageTracker = apiUsageTracker;
this.grainFactory = grainFactory;
this.messaging = messaging;
}
public virtual async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime today)
public virtual async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime today,
CancellationToken ct = default)
{
Guard.NotNull(app);
var appId = app.Id;
var isBlocked = false;
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, clientId);
isBlocked = usage >= client.ApiCallsLimit;
}
var (plan, _) = appPlansProvider.GetPlanForApp(app);
var limit = plan.MaxApiCalls;
var appId = app.Id;
var blocking = false;
var blockLimit = plan.MaxApiCalls;
if (limit > 0 || plan.BlockingApiCalls > 0)
if (blockLimit > 0 || plan.BlockingApiCalls > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null);
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null, ct);
if (IsOver10Percent(limit, usage) && IsAboutToBeLocked(today, limit, usage) && !HasNotifiedBefore(app.Id))
if (IsOver10Percent(blockLimit, usage) && IsAboutToBeLocked(today, blockLimit, usage) && !HasNotifiedBefore(app.Id))
{
var notification = new UsageNotification
var notification = new UsageTrackingCheck
{
AppId = appId,
AppName = app.Name,
Usage = usage,
UsageLimit = limit,
UsageLimit = blockLimit,
Users = GetUsers(app)
};
GetGrain().NotifyAsync(notification).Forget();
await messaging.PublishAsync(notification, ct: ct);
TrackNotified(appId);
}
isBlocked = isBlocked || (plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls);
blocking = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls;
}
return isBlocked;
}
if (!blocking)
{
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, clientId, ct);
private IUsageNotifierGrain GetGrain()
{
return grainFactory.GetGrain<IUsageNotifierGrain>(SingleGrain.Id);
blocking = usage >= client.ApiCallsLimit;
}
}
return blocking;
}
private bool HasNotifiedBefore(DomainId appId)

32
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs → backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierWorker.cs

@ -6,23 +6,21 @@
// ==========================================================================
using NodaTime;
using Orleans.Core;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
using Squidex.Messaging;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public sealed class UsageNotifierGrain : GrainBase, IUsageNotifierGrain
public sealed class UsageNotifierWorker : IMessageHandler<UsageTrackingCheck>
{
private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromDays(3);
private readonly IGrainState<State> grainState;
private readonly SimpleState<State> state;
private readonly INotificationSender notificationSender;
private readonly IUserResolver userResolver;
private readonly IClock clock;
[CollectionName("UsageNotifications")]
public sealed class State
@ -30,24 +28,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
public Dictionary<DomainId, DateTime> NotificationsSent { get; } = new Dictionary<DomainId, DateTime>();
}
public UsageNotifierGrain(IGrainIdentity identity,
IGrainState<State> grainState, INotificationSender notificationSender, IUserResolver userResolver, IClock clock)
: base(identity)
public IClock Clock { get; set; } = SystemClock.Instance;
public UsageNotifierWorker(IPersistenceFactory<State> persistenceFactory,
INotificationSender notificationSender, IUserResolver userResolver)
{
this.grainState = grainState;
this.notificationSender = notificationSender;
this.userResolver = userResolver;
this.clock = clock;
state = new SimpleState<State>(persistenceFactory, GetType(), DomainId.Create("Default"));
}
public async Task NotifyAsync(UsageNotification notification)
public async Task HandleAsync(UsageTrackingCheck notification,
CancellationToken ct)
{
if (!notificationSender.IsActive)
{
return;
}
var now = clock.GetCurrentInstant().ToDateTimeUtc();
var now = Clock.GetCurrentInstant().ToDateTimeUtc();
if (!HasBeenSentBefore(notification.AppId, now))
{
@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
foreach (var userId in notification.Users)
{
var user = await userResolver.FindByIdOrEmailAsync(userId);
var user = await userResolver.FindByIdOrEmailAsync(userId, ct);
if (user != null)
{
@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
private bool HasBeenSentBefore(DomainId appId, DateTime now)
{
if (grainState.Value.NotificationsSent.TryGetValue(appId, out var lastSent))
if (state.Value.NotificationsSent.TryGetValue(appId, out var lastSent))
{
var elapsed = now - lastSent;
@ -85,9 +85,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
private Task TrackNotifiedAsync(DomainId appId, DateTime now)
{
grainState.Value.NotificationsSent[appId] = now;
state.Value.NotificationsSent[appId] = now;
return grainState.WriteAsync();
return state.WriteAsync();
}
}
}

7
backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs

@ -11,10 +11,13 @@ namespace Squidex.Domain.Apps.Entities.Apps.Repositories
{
public interface IAppRepository
{
Task<Dictionary<string, DomainId>> QueryIdsAsync(string contributorId,
Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names,
CancellationToken ct = default);
Task<Dictionary<string, DomainId>> QueryIdsAsync(IEnumerable<string> names,
Task<IAppEntity?> FindAsync(DomainId id,
CancellationToken ct = default);
Task<IAppEntity?> FindAsync(string name,
CancellationToken ct = default);
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs

@ -44,9 +44,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
this.log = log;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
await next(context);
await next(context, ct);
if (context.IsCompleted && context.Command is CreateApp createApp)
{

3
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs

@ -74,7 +74,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
var asset = await assetLoader.GetAsync(
assetEvent.AppId.Id,
assetEvent.AssetId,
@event.Headers.EventStreamNumber());
@event.Headers.EventStreamNumber(),
ct);
if (asset != null)
{

26
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupGrain.cs → backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupProcess.cs

@ -5,36 +5,40 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Orleans.Runtime;
using Squidex.Hosting;
using Squidex.Infrastructure.Timers;
using tusdotnet.Interfaces;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCleanupGrain : Grain, IRemindable, IAssetCleanupGrain
public sealed class AssetCleanupProcess : IBackgroundProcess
{
private readonly ITusExpirationStore expirationStore;
private CompletionTimer timer;
public AssetCleanupGrain(ITusExpirationStore expirationStore)
public AssetCleanupProcess(ITusExpirationStore expirationStore)
{
this.expirationStore = expirationStore;
}
public override Task OnActivateAsync()
public Task StartAsync(
CancellationToken ct)
{
RegisterOrUpdateReminder("Cleanup", TimeSpan.Zero, TimeSpan.FromMinutes(10));
timer = new CompletionTimer((int)TimeSpan.FromMinutes(10).TotalMilliseconds, CleanupAsync);
return base.OnActivateAsync();
return Task.CompletedTask;
}
public Task ActivateAsync()
public Task StopAsync(
CancellationToken ct)
{
return Task.CompletedTask;
return timer?.StopAsync() ?? Task.CompletedTask;
}
public Task ReceiveReminder(string reminderName, TickStatus status)
public Task CleanupAsync(
CancellationToken ct)
{
return expirationStore.RemoveExpiredFilesAsync(default);
return expirationStore.RemoveExpiredFilesAsync(ct);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetTagsDeleter.cs

@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
public Task DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
return tagService.ClearAsync(app.Id, TagGroups.Assets);
return tagService.ClearAsync(app.Id, TagGroups.Assets, ct);
}
}
}

67
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Security.Cryptography;
using Orleans;
using Squidex.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure;
@ -14,7 +13,7 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain>
public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware<AssetCommand, AssetDomainObject, AssetDomainObject.State>
{
private readonly IAssetFileStore assetFileStore;
private readonly IAssetEnricher assetEnricher;
@ -23,13 +22,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
private readonly IEnumerable<IAssetMetadataSource> assetMetadataSources;
public AssetCommandMiddleware(
IGrainFactory grainFactory,
IDomainObjectFactory domainObjectFactory,
IDomainObjectCache domainObjectCache,
IAssetEnricher assetEnricher,
IAssetFileStore assetFileStore,
IAssetQueryService assetQuery,
IContextProvider contextProvider,
IEnumerable<IAssetMetadataSource> assetMetadataSources)
: base(grainFactory)
: base(domainObjectFactory, domainObjectCache)
{
this.assetEnricher = assetEnricher;
this.assetFileStore = assetFileStore;
@ -38,39 +38,41 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
this.contextProvider = contextProvider;
}
public override async Task HandleAsync(CommandContext context, NextDelegate next)
public override async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
switch (context.Command)
{
case CreateAsset create:
await UploadWithDuplicateCheckAsync(context, create, create.Duplicate, next);
await UploadWithDuplicateCheckAsync(context, create, create.Duplicate, next, ct);
break;
case UpsertAsset upsert:
await UploadWithDuplicateCheckAsync(context, upsert, upsert.Duplicate, next);
await UploadWithDuplicateCheckAsync(context, upsert, upsert.Duplicate, next, ct);
break;
case MoveAsset move:
await base.HandleAsync(context, next);
await base.HandleAsync(context, next, ct);
break;
case UpdateAsset upload:
await UploadAndHandleAsync(context, upload, next);
await UploadAndHandleAsync(context, upload, next, ct);
break;
default:
await base.HandleAsync(context, next);
await base.HandleAsync(context, next, ct);
break;
}
}
private async Task UploadWithDuplicateCheckAsync(CommandContext context, UploadAssetCommand command, bool duplicate, NextDelegate next)
private async Task UploadWithDuplicateCheckAsync(CommandContext context, UploadAssetCommand command, bool duplicate, NextDelegate next,
CancellationToken ct)
{
var tempFile = context.ContextId.ToString();
try
{
await EnrichWithHashAndUploadAsync(command, tempFile);
await EnrichWithHashAndUploadAsync(command, tempFile, ct);
if (!duplicate)
{
@ -78,51 +80,54 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
await assetQuery.FindByHashAsync(contextProvider.Context,
command.FileHash,
command.File.FileName,
command.File.FileSize);
command.File.FileSize,
ct);
if (existing != null)
{
context.Complete(new AssetDuplicate(existing));
await next(context);
await next(context, ct);
return;
}
}
await EnrichWithMetadataAsync(command);
await EnrichWithMetadataAsync(command, ct);
await base.HandleAsync(context, next);
await base.HandleAsync(context, next, ct);
}
finally
{
await assetFileStore.DeleteAsync(tempFile);
await assetFileStore.DeleteAsync(tempFile, ct);
await command.File.DisposeAsync();
}
}
private async Task UploadAndHandleAsync(CommandContext context, UploadAssetCommand command, NextDelegate next)
private async Task UploadAndHandleAsync(CommandContext context, UploadAssetCommand command, NextDelegate next,
CancellationToken ct)
{
var tempFile = context.ContextId.ToString();
try
{
await EnrichWithHashAndUploadAsync(command, tempFile);
await EnrichWithMetadataAsync(command);
await EnrichWithHashAndUploadAsync(command, tempFile, ct);
await EnrichWithMetadataAsync(command, ct);
await base.HandleAsync(context, next);
await base.HandleAsync(context, next, ct);
}
finally
{
await assetFileStore.DeleteAsync(tempFile);
await assetFileStore.DeleteAsync(tempFile, ct);
await command.File.DisposeAsync();
}
}
protected override async Task<object> EnrichResultAsync(CommandContext context, CommandResult result)
protected override async Task<object> EnrichResultAsync(CommandContext context, CommandResult result,
CancellationToken ct)
{
var payload = await base.EnrichResultAsync(context, result);
var payload = await base.EnrichResultAsync(context, result, ct);
if (payload is IAssetEntity asset)
{
@ -132,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
try
{
await assetFileStore.CopyAsync(tempFile, asset.AppId.Id, asset.AssetId, asset.FileVersion, null);
await assetFileStore.CopyAsync(tempFile, asset.AppId.Id, asset.AssetId, asset.FileVersion, null, ct);
}
catch (AssetAlreadyExistsException) when (context.Command is not UpsertAsset)
{
@ -142,20 +147,21 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
if (payload is not IEnrichedAssetEntity)
{
payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, default);
payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, ct);
}
}
return payload;
}
private async Task EnrichWithHashAndUploadAsync(UploadAssetCommand command, string tempFile)
private async Task EnrichWithHashAndUploadAsync(UploadAssetCommand command, string tempFile,
CancellationToken ct)
{
await using (var uploadStream = command.File.OpenRead())
{
using (var hashStream = new HasherStream(uploadStream, HashAlgorithmName.SHA256))
{
await assetFileStore.UploadAsync(tempFile, hashStream);
await assetFileStore.UploadAsync(tempFile, hashStream, ct);
command.FileHash = ComputeHash(command.File, hashStream);
}
@ -169,11 +175,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
return $"{steamHash}{file.FileName}{file.FileSize}".ToSha256Base64();
}
private async Task EnrichWithMetadataAsync(UploadAssetCommand command)
private async Task EnrichWithMetadataAsync(UploadAssetCommand command,
CancellationToken ct)
{
foreach (var metadataSource in assetMetadataSources)
{
await metadataSource.EnhanceAsync(command);
await metadataSource.EnhanceAsync(command, ct);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs

@ -15,7 +15,7 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
public sealed partial class AssetDomainObject
public partial class AssetDomainObject
{
public sealed class State : DomainObjectState<State>, IAssetEntity
{

42
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs

@ -20,7 +20,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
public sealed partial class AssetDomainObject : DomainObject<AssetDomainObject.State>
public partial class AssetDomainObject : DomainObject<AssetDomainObject.State>
{
private readonly IServiceProvider serviceProvider;
@ -29,8 +29,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
: base(id, persistence, log)
{
this.serviceProvider = serviceProvider;
Capacity = 2;
}
protected override bool IsDeleted(State snapshot)
@ -60,12 +58,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Equals(assetCommand.AssetId, Snapshot.Id);
}
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command)
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{
switch (command)
{
case UpsertAsset upsert:
return UpsertReturnAsync(upsert, async c =>
return UpsertReturnAsync(upsert, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -84,14 +83,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
}
return Snapshot;
});
}, ct);
case CreateAsset c:
return CreateReturnAsync(c, async create =>
case CreateAsset create:
return CreateReturnAsync(create, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await CreateCore(create, operation);
await CreateCore(c, operation);
if (Is.Change(Snapshot.ParentId, c.ParentId))
{
@ -99,53 +98,54 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
}
return Snapshot;
});
}, ct);
case AnnotateAsset c:
return UpdateReturnAsync(c, async c =>
case AnnotateAsset annotate:
return UpdateReturnAsync(annotate, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await AnnotateCore(c, operation);
return Snapshot;
});
}, ct);
case UpdateAsset update:
return UpdateReturnAsync(update, async c =>
return UpdateReturnAsync(update, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await UpdateCore(c, operation);
return Snapshot;
});
}, ct);
case MoveAsset move:
return UpdateReturnAsync(move, async c =>
return UpdateReturnAsync(move, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await MoveCore(c, operation);
return Snapshot;
});
}, ct);
case DeleteAsset delete when delete.Permanent:
return DeletePermanentAsync(delete, async c =>
return DeletePermanentAsync(delete, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await DeleteCore(c, operation);
});
}, ct);
case DeleteAsset delete:
return UpdateAsync(delete, async c =>
return UpdateAsync(delete, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await DeleteCore(c, operation);
});
}, ct);
default:
ThrowHelper.NotSupportedException();
return default!;

40
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObjectGrain.cs

@ -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);
}
}
}

21
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs

@ -48,39 +48,40 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Equals(assetFolderCommand.AssetFolderId, Snapshot.Id);
}
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command)
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{
switch (command)
{
case CreateAssetFolder c:
return CreateReturnAsync(c, async create =>
case CreateAssetFolder create:
return CreateReturnAsync(create, async (c, ct) =>
{
await CreateCore(create, c);
await CreateCore(c, c);
return Snapshot;
});
}, ct);
case MoveAssetFolder move:
return UpdateReturnAsync(move, async c =>
return UpdateReturnAsync(move, async (c, ct) =>
{
await MoveCore(c);
return Snapshot;
});
}, ct);
case RenameAssetFolder rename:
return UpdateReturnAsync(rename, async c =>
return UpdateReturnAsync(rename, async (c, ct) =>
{
await RenameCore(c);
return Snapshot;
});
}, ct);
case DeleteAssetFolder delete:
return Update(delete, c =>
{
Delete(c);
});
}, ct);
default:
ThrowHelper.NotSupportedException();

39
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObjectGrain.cs

@ -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;
}
}
}

26
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs

@ -26,27 +26,26 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
private readonly IContextProvider contextProvider;
private readonly ILogger<AssetsBulkUpdateCommandMiddleware> log;
private sealed record BulkTaskCommand(BulkTask Task, DomainId Id, ICommand Command)
{
}
private sealed record BulkTaskCommand(BulkTask Task, DomainId Id, ICommand Command,
CancellationToken CancellationToken);
private sealed record BulkTask(
ICommandBus Bus,
int JobIndex,
BulkUpdateJob CommandJob,
BulkUpdateAssets Command,
ConcurrentBag<BulkUpdateResultItem> Results
)
{
}
ConcurrentBag<BulkUpdateResultItem> Results,
CancellationToken CancellationToken);
public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider, ILogger<AssetsBulkUpdateCommandMiddleware> log)
{
this.contextProvider = contextProvider;
this.log = log;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is BulkUpdateAssets bulkUpdates)
{
@ -103,7 +102,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
i,
bulkUpdates.Jobs[i],
bulkUpdates,
results);
results,
ct);
if (!await createCommandsBlock.SendAsync(task))
{
@ -124,16 +124,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
}
else
{
await next(context);
await next(context, ct);
}
}
private async Task ExecuteCommandAsync(BulkTaskCommand bulkCommand)
{
var (task, id, command) = bulkCommand;
var (task, id, command, ct) = bulkCommand;
try
{
await task.Bus.PublishAsync(command);
await task.Bus.PublishAsync(command, ct);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex));
}
@ -157,7 +157,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
command.AssetId = id;
return new BulkTaskCommand(task, id, command);
return new BulkTaskCommand(task, id, command, task.CancellationToken);
}
catch (Exception ex)
{

17
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/IAssetGrain.cs

@ -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);
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs

@ -47,7 +47,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
}
}
public Task EnhanceAsync(UploadAssetCommand command)
public Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{
try
{

3
backend/src/Squidex.Domain.Apps.Entities/Assets/FileTypeAssetMetadataSource.cs

@ -12,7 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class FileTypeAssetMetadataSource : IAssetMetadataSource
{
public Task EnhanceAsync(UploadAssetCommand command)
public Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{
if (command.Tags != null)
{

5
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetLoader.cs

@ -11,6 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetLoader
{
Task<IAssetEntity?> GetAsync(DomainId appId, DomainId id, long version = EtagVersion.Any);
Task<IAssetEntity?> GetAsync(DomainId appId, DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default);
}
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetMetadataSource.cs

@ -13,7 +13,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
int Order => 0;
Task EnhanceAsync(UploadAssetCommand command);
Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct);
IEnumerable<string> Format(IAssetEntity asset);
}

9
backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs

@ -20,7 +20,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
this.assetThumbnailGenerator = assetThumbnailGenerator;
}
public async Task EnhanceAsync(UploadAssetCommand command)
public async Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{
if (command.Type == AssetType.Unknown || command.Type == AssetType.Image)
{
@ -30,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await using (var uploadStream = command.File.OpenRead())
{
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, mimeType);
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, mimeType, ct);
}
if (imageInfo != null)
@ -45,13 +46,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
await using (var tempStream = tempFile.OpenWrite())
{
await assetThumbnailGenerator.FixOrientationAsync(uploadStream, mimeType, tempStream);
await assetThumbnailGenerator.FixOrientationAsync(uploadStream, mimeType, tempStream, ct);
}
}
await using (var tempStream = tempFile.OpenRead())
{
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(tempStream, mimeType) ?? imageInfo;
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(tempStream, mimeType, ct) ?? imageInfo;
}
await command.File.DisposeAsync();

15
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs

@ -21,16 +21,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
private readonly IEnumerable<IAssetMetadataSource> assetMetadataSources;
private readonly IRequestCache requestCache;
private readonly IUrlGenerator urlGenerator;
private readonly IJsonSerializer jsonSerializer;
private readonly IJsonSerializer serializer;
public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources, IRequestCache requestCache,
IUrlGenerator urlGenerator, IJsonSerializer jsonSerializer)
IUrlGenerator urlGenerator, IJsonSerializer serializer)
{
this.tagService = tagService;
this.assetMetadataSources = assetMetadataSources;
this.requestCache = requestCache;
this.urlGenerator = urlGenerator;
this.jsonSerializer = jsonSerializer;
this.serializer = serializer;
}
public async Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset, Context context,
@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
u = url
};
var json = jsonSerializer.Serialize(token);
var json = serializer.Serialize(token);
asset.EditToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
@ -136,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
ct.ThrowIfCancellationRequested();
var tagsById = await CalculateTags(group);
var tagsById = await CalculateTagsAsync(group, ct);
foreach (var asset in group)
{
@ -156,11 +156,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
}
}
private async Task<Dictionary<string, string>> CalculateTags(IGrouping<DomainId, IAssetEntity> group)
private async Task<Dictionary<string, string>> CalculateTagsAsync(IGrouping<DomainId, IAssetEntity> group,
CancellationToken ct)
{
var uniqueIds = group.Where(x => x.Tags != null).SelectMany(x => x.Tags).ToHashSet();
return await tagService.DenormalizeTagsAsync(group.Key, TagGroups.Assets, uniqueIds);
return await tagService.DenormalizeTagsAsync(group.Key, TagGroups.Assets, uniqueIds, ct);
}
}
}

52
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetLoader.cs

@ -5,36 +5,60 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
public sealed class AssetLoader : IAssetLoader
{
private readonly IGrainFactory grainFactory;
private readonly IDomainObjectFactory domainObjectFactory;
private readonly IDomainObjectCache domainObjectCache;
public AssetLoader(IGrainFactory grainFactory)
public AssetLoader(IDomainObjectFactory domainObjectFactory, IDomainObjectCache domainObjectCache)
{
this.grainFactory = grainFactory;
this.domainObjectFactory = domainObjectFactory;
this.domainObjectCache = domainObjectCache;
}
public async Task<IAssetEntity?> GetAsync(DomainId appId, DomainId id, long version = EtagVersion.Any)
public async Task<IAssetEntity?> GetAsync(DomainId appId, DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("AssetLoader/GetAsync"))
var uniqueId = DomainId.Combine(appId, id);
var asset = await GetCachedAsync(uniqueId, version, ct);
if (asset == null)
{
asset = await GetAsync(uniqueId, version, ct);
}
if (asset == null || asset.Version <= EtagVersion.Empty || (version > EtagVersion.Any && asset.Version != version))
{
var key = DomainId.Combine(appId, id);
return null;
}
return asset;
}
var assetGrain = grainFactory.GetGrain<IAssetGrain>(key.ToString());
var assetState = await assetGrain.GetStateAsync(version);
private async Task<AssetDomainObject.State?> GetCachedAsync(DomainId uniqueId, long version,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("AssetLoader/GetCachedAsync"))
{
return await domainObjectCache.GetAsync<AssetDomainObject.State>(uniqueId, version, ct);
}
}
if (assetState == null || assetState.Version <= EtagVersion.Empty || (version > EtagVersion.Any && assetState.Version != version))
{
return null;
}
private async Task<AssetDomainObject.State> GetAsync(DomainId uniqueId, long version,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("AssetLoader/GetAsync"))
{
var contentObject = domainObjectFactory.Create<AssetDomainObject>(uniqueId);
return assetState;
return await contentObject.GetSnapshotAsync(version, ct);
}
}
}

18
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs

@ -24,20 +24,21 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
private readonly QueryModel queryModel = AssetQueryModel.Build();
private readonly IEdmModel edmModel;
private readonly IJsonSerializer jsonSerializer;
private readonly IJsonSerializer serializer;
private readonly ITagService tagService;
private readonly AssetOptions options;
public AssetQueryParser(IJsonSerializer jsonSerializer, ITagService tagService, IOptions<AssetOptions> options)
public AssetQueryParser(IJsonSerializer serializer, ITagService tagService, IOptions<AssetOptions> options)
{
this.jsonSerializer = jsonSerializer;
this.serializer = serializer;
this.tagService = tagService;
this.options = options.Value;
edmModel = queryModel.ConvertToEdm("Squidex", "Asset");
}
public virtual async Task<Q> ParseAsync(Context context, Q q)
public virtual async Task<Q> ParseAsync(Context context, Q q,
CancellationToken ct = default)
{
Guard.NotNull(context);
Guard.NotNull(q);
@ -46,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
var query = ParseClrQuery(q);
await TransformTagAsync(context, query);
await TransformTagAsync(context, query, ct);
WithSorting(query);
WithPaging(query, q);
@ -114,17 +115,18 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
}
}
private async Task TransformTagAsync(Context context, ClrQuery query)
private async Task TransformTagAsync(Context context, ClrQuery query,
CancellationToken ct)
{
if (query.Filter != null)
{
query.Filter = await FilterTagTransformer.TransformAsync(query.Filter, context.App.Id, tagService);
query.Filter = await FilterTagTransformer.TransformAsync(query.Filter, context.App.Id, tagService, ct);
}
}
private ClrQuery ParseJson(string json)
{
return queryModel.Parse(json, jsonSerializer);
return queryModel.Parse(json, serializer);
}
private ClrQuery ParseOData(string odata)

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs

@ -178,7 +178,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
using (Telemetry.Activities.StartActivity("AssetQueryService/QueryAsync"))
{
q = await queryParser.ParseAsync(context, q);
q = await queryParser.ParseAsync(context, q, ct);
var assets = await QueryCoreAsync(context, parentId, q, ct);

9
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs

@ -17,15 +17,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
private static readonly FilterTagTransformer Instance = new FilterTagTransformer();
public record struct Args(DomainId AppId, ITagService TagService);
public record struct Args(DomainId AppId, ITagService TagService, CancellationToken CancellationToken);
private FilterTagTransformer()
{
}
public static ValueTask<FilterNode<ClrValue>?> TransformAsync(FilterNode<ClrValue> nodeIn, DomainId appId, ITagService tagService)
public static ValueTask<FilterNode<ClrValue>?> TransformAsync(FilterNode<ClrValue> nodeIn, DomainId appId, ITagService tagService,
CancellationToken ct)
{
var args = new Args(appId, tagService);
var args = new Args(appId, tagService, ct);
return nodeIn.Accept(Instance, args);
}
@ -34,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
if (string.Equals(nodeIn.Path[0], nameof(IAssetEntity.Tags), StringComparison.OrdinalIgnoreCase) && nodeIn.Value.Value is string stringValue)
{
var tagNames = await args.TagService.GetTagIdsAsync(args.AppId, TagGroups.Assets, HashSet.Of(stringValue));
var tagNames = await args.TagService.GetTagIdsAsync(args.AppId, TagGroups.Assets, HashSet.Of(stringValue), args.CancellationToken);
if (tagNames.TryGetValue(stringValue, out var normalized))
{

2
backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs

@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
command.Actor = folderDeleted.Actor;
await commandBus.PublishAsync(command);
await commandBus.PublishAsync(command, default);
}
catch (Exception ex)
{

3
backend/src/Squidex.Domain.Apps.Entities/Assets/SvgAssetMetadataSource.cs

@ -16,7 +16,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
private const int FileSizeLimit = 2 * 1024 * 1024; // 2MB
public async Task EnhanceAsync(UploadAssetCommand command)
public async Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{
var isSvg =
command.File.MimeType == "image/svg+xml" ||

285
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs

@ -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());
}
}
}

305
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs

@ -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();
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs

@ -97,11 +97,13 @@ namespace Squidex.Domain.Apps.Entities.Backup
return attachmentEntry;
}
public async IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(IEventStreamNames eventStreams, IEventFormatter eventFormatter,
public async IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(
IEventStreamNames eventStreamNames,
IEventFormatter eventFormatter,
[EnumeratorCancellation] CancellationToken ct = default)
{
Guard.NotNull(eventFormatter);
Guard.NotNull(eventStreams);
Guard.NotNull(eventStreamNames);
while (!ct.IsCancellationRequested)
{

69
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs

@ -5,72 +5,95 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class BackupService : IBackupService, IDeleter
{
private readonly IGrainFactory grainFactory;
private readonly SimpleState<RestoreJob> restoreState;
private readonly IPersistenceFactory<BackupState> persistenceFactoryBackup;
private readonly IMessageBus messaging;
public BackupService(IGrainFactory grainFactory)
public BackupService(
IPersistenceFactory<RestoreJob> persistenceFactoryRestore,
IPersistenceFactory<BackupState> persistenceFactoryBackup,
IMessageBus messaging)
{
this.grainFactory = grainFactory;
this.persistenceFactoryBackup = persistenceFactoryBackup;
this.messaging = messaging;
restoreState = new SimpleState<RestoreJob>(persistenceFactoryRestore, GetType(), "Default");
}
Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
return BackupGrain(app.Id).ClearAsync();
return messaging.PublishAsync(new BackupClear(app.Id), ct: ct);
}
public Task StartBackupAsync(DomainId appId, RefToken actor)
public async Task StartBackupAsync(DomainId appId, RefToken actor,
CancellationToken ct = default)
{
return BackupGrain(appId).BackupAsync(actor);
var state = await GetStateAsync(appId, ct);
state.Value.EnsureCanStart();
await messaging.PublishAsync(new BackupStart(appId, actor), ct: ct);
}
public Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName)
public async Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName,
CancellationToken ct = default)
{
return RestoreGrain().RestoreAsync(url, actor, newAppName);
await restoreState.LoadAsync(ct);
restoreState.Value.EnsureCanStart();
await messaging.PublishAsync(new BackupRestore(actor, url, newAppName), ct: ct);
}
public Task DeleteBackupAsync(DomainId appId, DomainId backupId,
CancellationToken ct = default)
{
return BackupGrain(appId).DeleteAsync(backupId);
return messaging.PublishAsync(new BackupDelete(appId, backupId), ct: ct);
}
public Task<IRestoreJob> GetRestoreAsync(
public async Task<IRestoreJob> GetRestoreAsync(
CancellationToken ct = default)
{
return RestoreGrain().GetStateAsync();
await restoreState.LoadAsync(ct);
return restoreState.Value;
}
public Task<List<IBackupJob>> GetBackupsAsync(DomainId appId,
public async Task<List<IBackupJob>> GetBackupsAsync(DomainId appId,
CancellationToken ct = default)
{
return BackupGrain(appId).GetStateAsync();
var state = await GetStateAsync(appId, ct);
return state.Value.Jobs.OfType<IBackupJob>().ToList();
}
public async Task<IBackupJob?> GetBackupAsync(DomainId appId, DomainId backupId,
CancellationToken ct = default)
{
var state = await BackupGrain(appId).GetStateAsync();
var state = await GetStateAsync(appId, ct);
return state.Find(x => x.Id == backupId);
return state.Value.Jobs.Find(x => x.Id == backupId);
}
private IRestoreGrain RestoreGrain()
private async Task<SimpleState<BackupState>> GetStateAsync(DomainId appId,
CancellationToken ct)
{
return grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id);
}
var state = new SimpleState<BackupState>(persistenceFactoryBackup, GetType(), appId);
private IBackupGrain BackupGrain(DomainId appId)
{
return grainFactory.GetGrain<IBackupGrain>(appId.ToString());
await state.LoadAsync(ct);
return state;
}
}
}

90
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs

@ -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;
});
}
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs

@ -13,8 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
Stream OpenStream(DomainId backupId);
Task<IBackupWriter> OpenWriterAsync(Stream stream);
Task<IBackupWriter> OpenWriterAsync(Stream stream,
CancellationToken ct);
Task<IBackupReader> OpenReaderAsync(Uri url, DomainId id);
Task<IBackupReader> OpenReaderAsync(Uri url, DomainId id,
CancellationToken ct);
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs

@ -25,7 +25,9 @@ namespace Squidex.Domain.Apps.Entities.Backup
Task<bool> HasFileAsync(string name,
CancellationToken ct = default);
IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(IEventStreamNames eventStreams, IEventFormatter eventFormatter,
IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(
IEventStreamNames eventStreamNames,
IEventFormatter eventFormatter,
CancellationToken ct = default);
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs

@ -11,9 +11,11 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupService
{
Task StartBackupAsync(DomainId appId, RefToken actor);
Task StartBackupAsync(DomainId appId, RefToken actor,
CancellationToken ct = default);
Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName);
Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName,
CancellationToken ct = default);
Task<IRestoreJob> GetRestoreAsync(
CancellationToken ct = default);

15
backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs → backend/src/Squidex.Domain.Apps.Entities/Backup/Messages.cs

@ -5,15 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IRestoreGrain : IGrainWithStringKey
{
Task RestoreAsync(Uri url, RefToken actor, string? newAppName = null);
public sealed record BackupRestore(RefToken Actor, Uri Url, string? NewAppName = null);
public sealed record BackupStart(DomainId AppId, RefToken Actor);
public sealed record BackupDelete(DomainId AppId, DomainId Id);
Task<IRestoreJob> GetStateAsync();
}
public sealed record BackupClear(DomainId AppId);
}

435
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs

@ -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);
}
}
}

465
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs

@ -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);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs

@ -9,6 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Backup.State
{
public class BackupRestoreState
{
public RestoreJob Job { get; set; }
public RestoreJob? Job { get; set; }
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs

@ -5,10 +5,26 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public sealed class BackupState
{
public List<BackupJob> Jobs { get; } = new List<BackupJob>();
public void EnsureCanStart()
{
if (Jobs.Any(x => x.Status == JobStatus.Started))
{
throw new DomainException(T.Get("backups.alreadyRunning"));
}
if (Jobs.Count >= 10)
{
throw new DomainException(T.Get("backups.maxReached", new { max = 10 }));
}
}
}
}

9
backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs

@ -7,6 +7,7 @@
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
@ -31,5 +32,13 @@ namespace Squidex.Domain.Apps.Entities.Backup.State
public JobStatus Status { get; set; }
public string? NewAppName { get; set; }
public void EnsureCanStart()
{
if (Status == JobStatus.Started)
{
throw new DomainException(T.Get("backups.restoreRunning"));
}
}
}
}

22
backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs

@ -14,14 +14,15 @@ namespace Squidex.Domain.Apps.Entities.Backup
[ExcludeFromCodeCoverage]
public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation
{
private readonly IJsonSerializer jsonSerializer;
private readonly IJsonSerializer serializer;
public TempFolderBackupArchiveLocation(IJsonSerializer jsonSerializer)
public TempFolderBackupArchiveLocation(IJsonSerializer serializer)
{
this.jsonSerializer = jsonSerializer;
this.serializer = serializer;
}
public async Task<IBackupReader> OpenReaderAsync(Uri url, DomainId id)
public async Task<IBackupReader> OpenReaderAsync(Uri url, DomainId id,
CancellationToken ct)
{
Stream stream;
@ -40,12 +41,12 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
client.Timeout = TimeSpan.FromHours(1);
response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using (var sourceStream = await response.Content.ReadAsStreamAsync())
await using (var sourceStream = await response.Content.ReadAsStreamAsync(ct))
{
await sourceStream.CopyToAsync(stream);
await sourceStream.CopyToAsync(stream, ct);
}
}
}
@ -63,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
try
{
return new BackupReader(jsonSerializer, stream);
return new BackupReader(serializer, stream);
}
catch (IOException)
{
@ -94,9 +95,10 @@ namespace Squidex.Domain.Apps.Entities.Backup
return fileStream;
}
public Task<IBackupWriter> OpenWriterAsync(Stream stream)
public Task<IBackupWriter> OpenWriterAsync(Stream stream,
CancellationToken ct)
{
var writer = new BackupWriter(jsonSerializer, stream, true);
var writer = new BackupWriter(serializer, stream, true);
return Task.FromResult<IBackupWriter>(writer);
}

5
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs

@ -6,15 +6,18 @@
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments.Commands
{
public abstract class CommentsCommand : SquidexCommand, IAppCommand
public abstract class CommentsCommand : SquidexCommand, IAppCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; }
DomainId IAggregateCommand.AggregateId => DomainId.Combine(AppId.Id, CommentsId);
}
}

17
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs

@ -5,26 +5,29 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Comments.DomainObject;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments
{
public sealed class CommentsLoader : ICommentsLoader
{
private readonly IGrainFactory grainFactory;
private readonly IDomainObjectFactory domainObjectFactory;
public CommentsLoader(IGrainFactory grainFactory)
public CommentsLoader(IDomainObjectFactory domainObjectFactory)
{
this.grainFactory = grainFactory;
this.domainObjectFactory = domainObjectFactory;
}
public Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any)
public async Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default)
{
var grain = grainFactory.GetGrain<ICommentsGrain>(id.ToString());
var stream = domainObjectFactory.Create<CommentsStream>(id);
return grain.GetCommentsAsync(version);
await stream.LoadAsync(ct);
return stream.GetComments(version);
}
}
}

26
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs

@ -6,27 +6,25 @@
// ==========================================================================
using System.Text.RegularExpressions;
using Orleans;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{
public sealed class CommentsCommandMiddleware : ICommandMiddleware
public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware<CommentsCommand, CommentsStream>
{
private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromMilliseconds(100));
private readonly IGrainFactory grainFactory;
private readonly IUserResolver userResolver;
public CommentsCommandMiddleware(IGrainFactory grainFactory, IUserResolver userResolver)
public CommentsCommandMiddleware(IDomainObjectFactory domainObjectFactory, IUserResolver userResolver)
: base(domainObjectFactory)
{
this.grainFactory = grainFactory;
this.userResolver = userResolver;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
public override async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is CommentsCommand commentsCommand)
{
@ -34,21 +32,9 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{
await MentionUsersAsync(createComment);
}
await ExecuteCommandAsync(commentsCommand);
}
await next(context);
}
private Task ExecuteCommandAsync(CommentsCommand commentsCommand)
{
return GetGrain(commentsCommand).ExecuteAsync(commentsCommand);
}
private ICommentsGrain GetGrain(CommentsCommand commentsCommand)
{
return grainFactory.GetGrain<ICommentsGrain>(commentsCommand.CommentsId.ToString());
await base.HandleAsync(context, next, ct);
}
private static bool IsMention(CreateComment createComment)

45
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsGrain.cs → backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs

@ -5,24 +5,23 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans.Core;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection;
#pragma warning disable MA0022 // Return Task.FromResult instead of returning null
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{
public sealed class CommentsGrain : GrainBase, ICommentsGrain
public class CommentsStream : IAggregate
{
private readonly List<Envelope<CommentsEvent>> uncommittedEvents = new List<Envelope<CommentsEvent>>();
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>();
private readonly DomainId key;
private readonly IEventFormatter eventFormatter;
private readonly IEventStore eventStore;
private long version = EtagVersion.Empty;
@ -33,20 +32,22 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
get => version;
}
public CommentsGrain(IGrainIdentity identity,
public CommentsStream(
DomainId key,
IEventFormatter eventFormatter,
IEventStore eventStore)
: base(identity)
{
this.key = key;
this.eventFormatter = eventFormatter;
this.eventStore = eventStore;
}
public override async Task OnActivateAsync()
public virtual async Task LoadAsync(
CancellationToken ct)
{
streamName = $"comments-{Key}";
streamName = $"comments-{key}";
var storedEvents = await eventStore.QueryReverseAsync(streamName, 100);
var storedEvents = await eventStore.QueryReverseAsync(streamName, 100, ct);
foreach (var @event in storedEvents)
{
@ -58,7 +59,8 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
}
}
public Task<CommandResult> ExecuteAsync(CommentsCommand command)
public virtual Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{
switch (command)
{
@ -68,23 +70,23 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
GuardComments.CanCreate(c);
Create(c);
});
}, ct);
case UpdateComment updateComment:
return Upsert(updateComment, c =>
{
GuardComments.CanUpdate(c, Key.ToString(), events);
GuardComments.CanUpdate(c, key.ToString(), events);
Update(c);
});
}, ct);
case DeleteComment deleteComment:
return Upsert(deleteComment, c =>
{
GuardComments.CanDelete(c, Key.ToString(), events);
GuardComments.CanDelete(c, key.ToString(), events);
Delete(c);
});
}, ct);
default:
ThrowHelper.NotSupportedException();
@ -92,14 +94,15 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
}
}
private async Task<CommandResult> Upsert<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : CommentsCommand
private async Task<CommandResult> Upsert<TCommand>(TCommand command, Action<TCommand> handler,
CancellationToken ct) where TCommand : CommentsCommand
{
Guard.NotNull(command);
Guard.NotNull(handler);
if (command.ExpectedVersion > EtagVersion.Any && command.ExpectedVersion != Version)
{
throw new DomainObjectVersionException(Key.ToString(), Version, command.ExpectedVersion);
throw new DomainObjectVersionException(key.ToString(), Version, command.ExpectedVersion);
}
var previousVersion = version;
@ -114,12 +117,12 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
var eventData = uncommittedEvents.Select(x => eventFormatter.ToEventData(x, commitId)).ToList();
await eventStore.AppendAsync(commitId, streamName, previousVersion, eventData);
await eventStore.AppendAsync(commitId, streamName, previousVersion, eventData, ct);
}
events.AddRange(uncommittedEvents);
return CommandResult.Empty(Key, Version, previousVersion);
return CommandResult.Empty(key, Version, previousVersion);
}
catch
{
@ -155,14 +158,14 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
version++;
}
public List<Envelope<CommentsEvent>> GetUncommittedEvents()
public virtual List<Envelope<CommentsEvent>> GetUncommittedEvents()
{
return uncommittedEvents;
}
public Task<CommentsResult> GetCommentsAsync(long sinceVersion = EtagVersion.Any)
public virtual CommentsResult GetComments(long sinceVersion = EtagVersion.Any)
{
return Task.FromResult(CommentsResult.FromEvents(events, Version, (int)sinceVersion));
return CommentsResult.FromEvents(events, Version, (int)sinceVersion);
}
}
}

21
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/ICommentsGrain.cs

@ -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);
}
}

35
backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs

@ -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());
}
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs

@ -11,6 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
{
public interface ICommentsLoader
{
Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any);
Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default);
}
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs

@ -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);
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs

@ -11,6 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
{
public interface IWatchingService
{
Task<string[]> GetWatchingUsersAsync(DomainId appId, string resource, string userId);
Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId,
CancellationToken ct = default);
}
}

86
backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.cs

@ -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();
}
}
}
}

62
backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingService.cs

@ -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);
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -83,7 +83,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
await contentLoader.GetAsync(
contentEvent.AppId.Id,
contentEvent.ContentId,
@event.Headers.EventStreamNumber());
@event.Headers.EventStreamNumber(),
ct);
if (content != null)
{
@ -117,7 +118,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
await contentLoader.GetAsync(
content.AppId.Id,
content.Id,
content.Version - 1);
content.Version - 1,
ct);
if (previousContent != null)
{

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save