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; command.FromRule = true;
await commandBus.PublishAsync(command); await commandBus.PublishAsync(command, ct);
return Result.Success($"Commented: {job.Text}"); 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; command.FromRule = true;
await commandBus.PublishAsync(command); await commandBus.PublishAsync(command, ct);
return Result.Success($"Created to: {job.SchemaId.Name}"); 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.AppId = NoApp;
command.FromRule = true; command.FromRule = true;
await commandBus.PublishAsync(command); await commandBus.PublishAsync(command, ct);
return Result.Success($"Notified: {job.Text}"); 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; this.log = log;
} }
public async Task EnhanceAsync(UploadAssetCommand command) public async Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{ {
try try
{ {
@ -55,7 +56,7 @@ namespace Squidex.Extensions.Assets.Azure
{ {
await using (var stream = command.File.OpenRead()) 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>(); 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; 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") if (context.Command is UpdateContent update && context.IsCompleted && update.SchemaId.Name == "source")
{ {
@ -36,7 +37,8 @@ namespace Squidex.Extensions.Samples.Middleware
await contentLoader.GetAsync( await contentLoader.GetAsync(
content.AppId.Id, content.AppId.Id,
content.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. // 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); var oldReferenceId = GetReference(contentPrevious?.Data);
@ -50,7 +52,7 @@ namespace Squidex.Extensions.Samples.Middleware
if (oldReferenceId != null) 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) if (oldReferenced != null)
{ {
@ -59,13 +61,13 @@ namespace Squidex.Extensions.Samples.Middleware
// Remove the reference from the old referenced content. // Remove the reference from the old referenced content.
data.Remove("referencing"); data.Remove("referencing");
await UpdateReferencing(context, oldReferenced, data); await UpdateReferencing(context, oldReferenced, data, ct);
} }
} }
if (newReferenceId != null) 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) if (newReferenced != null)
{ {
@ -77,14 +79,16 @@ namespace Squidex.Extensions.Samples.Middleware
["iv"] = JsonValue.Array(content.Id) ["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 await context.CommandBus.PublishAsync(new UpdateContent
{ {
AppId = reference.AppId, AppId = reference.AppId,
@ -93,9 +97,8 @@ namespace Squidex.Extensions.Samples.Middleware
DoNotScript = true, DoNotScript = true,
DoNotValidate = true, DoNotValidate = true,
Data = data, Data = data,
// Also set the expected version, otherwise it will be overriden with the version from the request.
ExpectedVersion = reference.Version ExpectedVersion = reference.Version
}); }, ct);
} }
private static string GetReference(ContentData data) private static string GetReference(ContentData data)

3
backend/i18n/frontend_en.json

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

3
backend/i18n/frontend_it.json

@ -582,11 +582,14 @@
"eventConsumers.position": "Posizione", "eventConsumers.position": "Posizione",
"eventConsumers.refreshTooltip": "Aggiorna gli eventi degli utenti", "eventConsumers.refreshTooltip": "Aggiorna gli eventi degli utenti",
"eventConsumers.reloaded": "Eventi degli utenti aggiornati.", "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.resetFailed": "Non è stato possibile ripristinare gli eventi degli utenti. Per favore ricarica.",
"eventConsumers.resetTooltip": "Ripristina gli event degli utenti", "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.startFailed": "Non è stato possibile far partire l'evento dell'utente. Per favore ricarica.",
"eventConsumers.startTooltip": "Inizia l'evento utente", "eventConsumers.startTooltip": "Inizia l'evento utente",
"eventConsumers.stopFailed": "Non è stato possibile fermare l'evento dell'utente. Per favore ricarica.", "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", "eventConsumers.stopTooltip": "Interrompi l'evento dell'utente",
"features.loadFailed": "Non è stato possibile caricare le funzionalità. Per favore ricarica.", "features.loadFailed": "Non è stato possibile caricare le funzionalità. Per favore ricarica.",
"history.loadFailed": "Non è stato possibile caricare la cronologia. 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.position": "Positie",
"eventConsumers.refreshTooltip": "Ververs evenementgebruikers", "eventConsumers.refreshTooltip": "Ververs evenementgebruikers",
"eventConsumers.reloaded": "Evenementconsumenten herladen.", "eventConsumers.reloaded": "Evenementconsumenten herladen.",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "Kan gebeurtenisgebruiker niet resetten. Laad opnieuw.", "eventConsumers.resetFailed": "Kan gebeurtenisgebruiker niet resetten. Laad opnieuw.",
"eventConsumers.resetTooltip": "Reset Event Consumer", "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.startFailed": "Het starten van een evenementconsument is mislukt. Laad opnieuw.",
"eventConsumers.startTooltip": "Start Event Consumer", "eventConsumers.startTooltip": "Start Event Consumer",
"eventConsumers.stopFailed": "Kan de gebruiker van het evenement niet stoppen. Laad opnieuw.", "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", "eventConsumers.stopTooltip": "Stop Event Consumer",
"features.loadFailed": "Laden van functies is mislukt. Laad opnieuw.", "features.loadFailed": "Laden van functies is mislukt. Laad opnieuw.",
"history.loadFailed": "Kan geschiedenis niet laden. Laad opnieuw.", "history.loadFailed": "Kan geschiedenis niet laden. Laad opnieuw.",

3
backend/i18n/frontend_zh.json

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

3
backend/i18n/source/frontend_en.json

@ -582,11 +582,14 @@
"eventConsumers.position": "Position", "eventConsumers.position": "Position",
"eventConsumers.refreshTooltip": "Refresh event consumers", "eventConsumers.refreshTooltip": "Refresh event consumers",
"eventConsumers.reloaded": "Event Consumers reloaded.", "eventConsumers.reloaded": "Event Consumers reloaded.",
"eventConsumers.reset": "Event consumer will be reset soon",
"eventConsumers.resetFailed": "Failed to reset event consumer. Please reload.", "eventConsumers.resetFailed": "Failed to reset event consumer. Please reload.",
"eventConsumers.resetTooltip": "Reset Event Consumer", "eventConsumers.resetTooltip": "Reset Event Consumer",
"eventConsumers.started": "Event consumer will be started soon",
"eventConsumers.startFailed": "Failed to start event consumer. Please reload.", "eventConsumers.startFailed": "Failed to start event consumer. Please reload.",
"eventConsumers.startTooltip": "Start Event Consumer", "eventConsumers.startTooltip": "Start Event Consumer",
"eventConsumers.stopFailed": "Failed to stop event consumer. Please reload.", "eventConsumers.stopFailed": "Failed to stop event consumer. Please reload.",
"eventConsumers.stopped": "Event consumer will be stopped soon",
"eventConsumers.stopTooltip": "Stop Event Consumer", "eventConsumers.stopTooltip": "Stop Event Consumer",
"features.loadFailed": "Failed to load features. Please reload.", "features.loadFailed": "Failed to load features. Please reload.",
"history.loadFailed": "Failed to load history. 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) private IEnumerable<IMigration?> ResolveMigrators(int version)
{ {
yield return serviceProvider.GetRequiredService<StopEventConsumers>();
// Version 06: Convert Event store. Must always be executed first. // Version 06: Convert Event store. Must always be executed first.
if (version < 6) if (version < 6)
{ {
@ -121,8 +119,6 @@ namespace Migrations
{ {
yield return serviceProvider.GetRequiredService<ConvertRuleEventsJson>(); 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 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) public ContentFieldData? ConvertField(IRootField field, ContentFieldData source)
@ -54,7 +54,7 @@ namespace Squidex.Domain.Apps.Core.ConvertContent
{ {
try try
{ {
return !JsonValueValidator.IsValid(field, source, jsonSerializer); return !JsonValueValidator.IsValid(field, source, serializer);
} }
catch 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 const string GlobalFallback = "null";
private static readonly Regex RegexPatternOld = new Regex(@"^(?<FullPath>(?<Type>[^_]*)_(?<Path>[^\s]*))", RegexOptions.Compiled | RegexOptions.ExplicitCapture); 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 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 IEnumerable<IRuleEventFormatter> formatters;
private readonly ITemplateEngine templateEngine; private readonly ITemplateEngine templateEngine;
private readonly IScriptEngine scriptEngine; 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.formatters = formatters;
this.templateEngine = templateEngine; this.templateEngine = templateEngine;
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
@ -81,14 +81,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{ {
var payload = @event; var payload = @event;
return jsonSerializer.Serialize(payload); return serializer.Serialize(payload);
} }
public virtual string ToEnvelope(EnrichedEvent @event) public virtual string ToEnvelope(EnrichedEvent @event)
{ {
var payload = new { type = @event.Name, payload = @event, timestamp = @event.Timestamp }; 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) 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 TypeNameRegistry typeNameRegistry;
private readonly RuleOptions ruleOptions; private readonly RuleOptions ruleOptions;
private readonly IEventEnricher eventEnricher; private readonly IEventEnricher eventEnricher;
private readonly IJsonSerializer jsonSerializer; private readonly IJsonSerializer serializer;
private readonly IClock clock;
private readonly ILogger<RuleService> log; private readonly ILogger<RuleService> log;
public IClock Clock { get; set; } = SystemClock.Instance;
public RuleService( public RuleService(
IOptions<RuleOptions> ruleOptions, IOptions<RuleOptions> ruleOptions,
IEnumerable<IRuleTriggerHandler> ruleTriggerHandlers, IEnumerable<IRuleTriggerHandler> ruleTriggerHandlers,
IEnumerable<IRuleActionHandler> ruleActionHandlers, IEnumerable<IRuleActionHandler> ruleActionHandlers,
IEventEnricher eventEnricher, IEventEnricher eventEnricher,
IJsonSerializer jsonSerializer, IJsonSerializer serializer,
IClock clock,
ILogger<RuleService> log, ILogger<RuleService> log,
TypeNameRegistry typeNameRegistry) TypeNameRegistry typeNameRegistry)
{ {
this.typeNameRegistry = typeNameRegistry; this.typeNameRegistry = typeNameRegistry;
this.eventEnricher = eventEnricher;
this.ruleOptions = ruleOptions.Value; this.ruleOptions = ruleOptions.Value;
this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType); this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType);
this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType); this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType);
this.eventEnricher = eventEnricher; this.serializer = serializer;
this.jsonSerializer = jsonSerializer;
this.clock = clock;
this.log = log; this.log = log;
} }
@ -94,7 +89,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
yield break; yield break;
} }
var now = clock.GetCurrentInstant(); var now = Clock.GetCurrentInstant();
await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct)) await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct))
{ {
@ -204,7 +199,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return; return;
} }
var now = clock.GetCurrentInstant(); var now = Clock.GetCurrentInstant();
var eventTime = var eventTime =
@event.Headers.ContainsKey(CommonHeaders.Timestamp) ? @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 (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action);
var json = jsonSerializer.Serialize(data); var json = serializer.Serialize(data);
job.ActionData = json; job.ActionData = json;
job.ActionName = actionName; job.ActionName = actionName;
@ -367,7 +362,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var actionType = typeNameRegistry.GetType(actionName); var actionType = typeNameRegistry.GetType(actionName);
var actionHandler = ruleActionHandlers[actionType]; 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())) 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 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(); 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() 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) ResolvedComponents components)
{ {
Guard.NotNull(field); Guard.NotNull(field);
Guard.NotNull(value); Guard.NotNull(value);
var args = new Args(value, jsonSerializer, components); var args = new Args(value, serializer, components);
return field.Accept(Instance, args); 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) 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) 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(); 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() 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(field);
Guard.NotNull(value); Guard.NotNull(value);
var args = new Args(value, jsonSerializer); var args = new Args(value, serializer);
return field.Accept(Instance, args); return field.Accept(Instance, args);
} }
@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
public bool Visit(IField<GeolocationFieldProperties> field, Args args) 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; 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 ConcurrentBag<ValidationError> errors = new ConcurrentBag<ValidationError>();
private readonly Scheduler scheduler = new Scheduler(); private readonly Scheduler scheduler = new Scheduler();
public IJsonSerializer JsonSerializer { get; } public IJsonSerializer Serializer { get; }
public DomainId ContentId { get; } public DomainId ContentId { get; }
@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
} }
public RootContext( public RootContext(
IJsonSerializer jsonSerializer, IJsonSerializer serializer,
NamedId<DomainId> appId, NamedId<DomainId> appId,
NamedId<DomainId> schemaId, NamedId<DomainId> schemaId,
Schema schema, Schema schema,
@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
AppId = appId; AppId = appId;
Components = components; Components = components;
ContentId = contentId; ContentId = contentId;
JsonSerializer = jsonSerializer; Serializer = serializer;
Schema = schema; Schema = schema;
SchemaId = schemaId; 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; typedValue = jsonValue.Value;
var (json, error) = JsonValueConverter.ConvertValue(field, jsonValue, var (json, error) = JsonValueConverter.ConvertValue(field, jsonValue,
context.Root.JsonSerializer, context.Root.Serializer,
context.Root.Components); context.Root.Components);
if (error != null) 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 sealed class MongoAppRepository : MongoSnapshotStoreBase<AppDomainObject.State, MongoAppEntity>, IAppRepository, IDeleter
{ {
public MongoAppRepository(IMongoDatabase database, JsonSerializer jsonSerializer) public MongoAppRepository(IMongoDatabase database, JsonSerializer serializer)
: base(database, jsonSerializer) : base(database, serializer)
{ {
} }
@ -82,5 +82,44 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
return result; 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 MongoDB.Driver;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps; 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.DomainObject;
using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Rules namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
{ {
public sealed class MongoRuleRepository : MongoSnapshotStoreBase<RuleDomainObject.State, MongoRuleEntity>, IRuleRepository, IDeleter public sealed class MongoRuleRepository : MongoSnapshotStoreBase<RuleDomainObject.State, MongoRuleEntity>, IRuleRepository, IDeleter
{ {
public MongoRuleRepository(IMongoDatabase database, JsonSerializer jsonSerializer) public MongoRuleRepository(IMongoDatabase database, JsonSerializer serializer)
: base(database, jsonSerializer) : 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); 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) 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 MongoDB.Driver;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps; 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.DomainObject;
using Squidex.Domain.Apps.Entities.Schemas.Repositories; using Squidex.Domain.Apps.Entities.Schemas.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas
{ {
public sealed class MongoSchemaRepository : MongoSnapshotStoreBase<SchemaDomainObject.State, MongoSchemaEntity>, ISchemaRepository, IDeleter public sealed class MongoSchemaRepository : MongoSnapshotStoreBase<SchemaDomainObject.State, MongoSchemaEntity>, ISchemaRepository, IDeleter
{ {
public MongoSchemaRepository(IMongoDatabase database, JsonSerializer jsonSerializer) public MongoSchemaRepository(IMongoDatabase database, JsonSerializer serializer)
: base(database, jsonSerializer) : 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); return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct);
} }
public async Task<Dictionary<string, DomainId>> QueryIdsAsync(DomainId appId, public async Task<List<ISchemaEntity>> QueryAllAsync(DomainId appId, CancellationToken ct = default)
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, public async Task<ISchemaEntity?> FindAsync(DomainId appId, DomainId id,
CancellationToken ct) 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 entity =
var indexedName = entity["_sn"].AsString; 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 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) if (context.IsCompleted && context.Command is CreateApp createApp)
{ {
@ -26,7 +27,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
command.AppId = appId; 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); 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Apps.DomainObject; using Squidex.Domain.Apps.Entities.Apps.DomainObject;
using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public sealed class AppPermanentDeleter : IEventConsumer public sealed class AppPermanentDeleter : IEventConsumer
{ {
private readonly IEnumerable<IDeleter> deleters; private readonly IEnumerable<IDeleter> deleters;
private readonly IGrainFactory grainFactory; private readonly IDomainObjectFactory factory;
private readonly HashSet<string> consumingTypes; private readonly HashSet<string> consumingTypes;
public string Name public string Name
@ -30,11 +30,10 @@ namespace Squidex.Domain.Apps.Entities.Apps
get => "^app-"; 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.deleters = deleters.OrderBy(x => x.Order).ToList();
this.factory = factory;
this.grainFactory = grainFactory;
// Compute the event types names once for performance reasons and use hashset for extensibility. // Compute the event types names once for performance reasons and use hashset for extensibility.
consumingTypes = new HashSet<string> consumingTypes = new HashSet<string>
@ -88,9 +87,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
using (Telemetry.Activities.StartActivity("RemoveAppFromSystem")) using (Telemetry.Activities.StartActivity("RemoveAppFromSystem"))
{ {
// Bypass our normal app resolve process, so that we can also retrieve the deleted app. // 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 the app does not exist, the version is lower than zero.
if (app.Version < 0) if (app.Version < 0)
@ -102,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
using (Telemetry.Activities.StartActivity(deleter.GetType().Name)) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
public sealed class AppUISettings : IAppUISettings, IDeleter 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, async Task IDeleter.DeleteContributorAsync(DomainId appId, string contributorId,
CancellationToken ct) CancellationToken ct)
{ {
await GetGrain(appId, null).ClearAsync(); await ClearAsync(appId, contributorId, ct);
} }
async Task IDeleter.DeleteAppAsync(IAppEntity app, async Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) CancellationToken ct)
{ {
await GetGrain(app.Id, null).ClearAsync(); await ClearAsync(app.Id, null, ct);
foreach (var userId in app.Contributors.Keys) 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) 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, public async Task BackupAsync(BackupContext context,
CancellationToken ct) 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); 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); 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, 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 rebuilder.InsertManyAsync<AppDomainObject, AppDomainObject.State>(Enumerable.Repeat(context.AppId, 1), 1, default);
await appsIndex.RegisterAsync(context.AppId, appName);
await appsIndex.RemoveReservationAsync(appReservation); 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -14,51 +13,51 @@ using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject 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 IAppImageStore appImageStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IContextProvider contextProvider; private readonly IContextProvider contextProvider;
public AppCommandMiddleware( public AppCommandMiddleware(IDomainObjectFactory domainObjectFactory,
IGrainFactory grainFactory, IAppImageStore appImageStore, IAssetThumbnailGenerator assetThumbnailGenerator, IContextProvider contextProvider)
IAppImageStore appImageStore, : base(domainObjectFactory)
IAssetThumbnailGenerator assetThumbnailGenerator,
IContextProvider contextProvider)
: base(grainFactory)
{ {
this.appImageStore = appImageStore; this.appImageStore = appImageStore;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetThumbnailGenerator = assetThumbnailGenerator;
this.contextProvider = contextProvider; 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) 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) if (result.Payload is IAppEntity app)
{ {
contextProvider.Context.App = 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; var file = uploadImage.File;
await using (var uploadStream = file.OpenRead()) 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) if (image == null)
{ {
@ -68,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
await using (var uploadStream = file.OpenRead()) 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 namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{ {
public sealed partial class AppDomainObject public partial class AppDomainObject
{ {
[CollectionName("Apps")] [CollectionName("Apps")]
public sealed class State : DomainObjectState<State>, IAppEntity 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 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; 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); 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) switch (command)
{ {
@ -62,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Create(c); Create(c);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateApp update: case UpdateApp update:
return UpdateReturn(update, c => return UpdateReturn(update, c =>
@ -72,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Update(c); Update(c);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateAppSettings updateSettings: case UpdateAppSettings updateSettings:
return UpdateReturn(updateSettings, c => return UpdateReturn(updateSettings, c =>
@ -82,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateSettings(c); UpdateSettings(c);
return Snapshot; return Snapshot;
}); }, ct);
case UploadAppImage uploadImage: case UploadAppImage uploadImage:
return UpdateReturn(uploadImage, c => return UpdateReturn(uploadImage, c =>
@ -92,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UploadImage(c); UploadImage(c);
return Snapshot; return Snapshot;
}); }, ct);
case RemoveAppImage removeImage: case RemoveAppImage removeImage:
return UpdateReturn(removeImage, c => return UpdateReturn(removeImage, c =>
@ -102,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RemoveImage(c); RemoveImage(c);
return Snapshot; return Snapshot;
}); }, ct);
case ConfigureAssetScripts configureAssetScripts: case ConfigureAssetScripts configureAssetScripts:
return UpdateReturn(configureAssetScripts, c => return UpdateReturn(configureAssetScripts, c =>
@ -112,17 +113,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
ConfigureAssetScripts(c); ConfigureAssetScripts(c);
return Snapshot; return Snapshot;
}); }, ct);
case AssignContributor assignContributor: case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async c => return UpdateReturnAsync(assignContributor, async (c, ct) =>
{ {
await GuardAppContributors.CanAssign(c, Snapshot, UserResolver(), GetPlan()); await GuardAppContributors.CanAssign(c, Snapshot, UserResolver(), GetPlan());
AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId)); AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
return Snapshot; return Snapshot;
}); }, ct);
case RemoveContributor removeContributor: case RemoveContributor removeContributor:
return UpdateReturn(removeContributor, c => return UpdateReturn(removeContributor, c =>
@ -132,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RemoveContributor(c); RemoveContributor(c);
return Snapshot; return Snapshot;
}); }, ct);
case AttachClient attachClient: case AttachClient attachClient:
return UpdateReturn(attachClient, c => return UpdateReturn(attachClient, c =>
@ -142,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AttachClient(c); AttachClient(c);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateClient updateClient: case UpdateClient updateClient:
return UpdateReturn(updateClient, c => return UpdateReturn(updateClient, c =>
@ -152,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateClient(c); UpdateClient(c);
return Snapshot; return Snapshot;
}); }, ct);
case RevokeClient revokeClient: case RevokeClient revokeClient:
return UpdateReturn(revokeClient, c => return UpdateReturn(revokeClient, c =>
@ -162,7 +163,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RevokeClient(c); RevokeClient(c);
return Snapshot; return Snapshot;
}); }, ct);
case AddWorkflow addWorkflow: case AddWorkflow addWorkflow:
return UpdateReturn(addWorkflow, c => return UpdateReturn(addWorkflow, c =>
@ -172,7 +173,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AddWorkflow(c); AddWorkflow(c);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateWorkflow updateWorkflow: case UpdateWorkflow updateWorkflow:
return UpdateReturn(updateWorkflow, c => return UpdateReturn(updateWorkflow, c =>
@ -182,7 +183,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateWorkflow(c); UpdateWorkflow(c);
return Snapshot; return Snapshot;
}); }, ct);
case DeleteWorkflow deleteWorkflow: case DeleteWorkflow deleteWorkflow:
return UpdateReturn(deleteWorkflow, c => return UpdateReturn(deleteWorkflow, c =>
@ -192,7 +193,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
DeleteWorkflow(c); DeleteWorkflow(c);
return Snapshot; return Snapshot;
}); }, ct);
case AddLanguage addLanguage: case AddLanguage addLanguage:
return UpdateReturn(addLanguage, c => return UpdateReturn(addLanguage, c =>
@ -202,7 +203,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AddLanguage(c); AddLanguage(c);
return Snapshot; return Snapshot;
}); }, ct);
case RemoveLanguage removeLanguage: case RemoveLanguage removeLanguage:
return UpdateReturn(removeLanguage, c => return UpdateReturn(removeLanguage, c =>
@ -212,7 +213,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RemoveLanguage(c); RemoveLanguage(c);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateLanguage updateLanguage: case UpdateLanguage updateLanguage:
return UpdateReturn(updateLanguage, c => return UpdateReturn(updateLanguage, c =>
@ -222,7 +223,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateLanguage(c); UpdateLanguage(c);
return Snapshot; return Snapshot;
}); }, ct);
case AddRole addRole: case AddRole addRole:
return UpdateReturn(addRole, c => return UpdateReturn(addRole, c =>
@ -232,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
AddRole(c); AddRole(c);
return Snapshot; return Snapshot;
}); }, ct);
case DeleteRole deleteRole: case DeleteRole deleteRole:
return UpdateReturn(deleteRole, c => return UpdateReturn(deleteRole, c =>
@ -242,7 +243,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
DeleteRole(c); DeleteRole(c);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateRole updateRole: case UpdateRole updateRole:
return UpdateReturn(updateRole, c => return UpdateReturn(updateRole, c =>
@ -252,10 +253,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
UpdateRole(c); UpdateRole(c);
return Snapshot; return Snapshot;
}); }, ct);
case ChangePlan changePlan: case ChangePlan changePlan:
return UpdateReturnAsync(changePlan, async c => return UpdateReturnAsync(changePlan, async (c, ct) =>
{ {
GuardApp.CanChangePlan(c, Snapshot, AppPlansProvider()); GuardApp.CanChangePlan(c, Snapshot, AppPlansProvider());
@ -278,15 +279,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return result; return result;
} }
}); }, ct);
case DeleteApp delete: case DeleteApp delete:
return UpdateAsync(delete, async c => return UpdateAsync(delete, async (c, ct) =>
{ {
await AppPlanBillingManager().ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null, null); await AppPlanBillingManager().ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null, null);
DeleteApp(c); DeleteApp(c);
}); }, ct);
default: default:
ThrowHelper.NotSupportedException(); 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 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Caching; using Squidex.Caching;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.DomainObject;
using Squidex.Domain.Apps.Entities.Apps.Repositories; using Squidex.Domain.Apps.Entities.Apps.Repositories;
using Squidex.Hosting;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Squidex.Shared; using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Apps.Indexes 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 static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IAppRepository appRepository; private readonly IAppRepository appRepository;
private readonly IGrainFactory grainFactory; private readonly IReplicatedCache appCache;
private readonly IReplicatedCache grainCache; 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.appRepository = appRepository;
this.grainFactory = grainFactory; this.appCache = appCache;
this.grainCache = grainCache;
namesState = new NameReservationState(persistenceFactory, "Apps");
} }
public Task RegisterAsync(DomainId id, string name, public Task InitializeAsync(
CancellationToken ct = default) CancellationToken ct)
{ {
return Cache().AddAsync(id, name); return namesState.LoadAsync(ct);
} }
public Task RemoveReservationAsync(string? token, public Task RemoveReservationAsync(string? token,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Cache().RemoveReservationAsync(token); return namesState.RemoveReservationAsync(token, ct);
} }
public async Task<string?> ReserveAsync(DomainId id, string name, public async Task<string?> ReserveAsync(DomainId id, string name,
CancellationToken ct = default) 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, 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")) using (Telemetry.Activities.StartActivity("AppsIndex/GetAppsForUserAsync"))
{ {
var ids = var apps = await appRepository.QueryAllAsync(userId, permissions.ToAppNames(), ct);
await Task.WhenAll(
GetAppIdsByUserAsync(userId),
GetAppIdsAsync(permissions.ToAppNames()));
var apps = foreach (var app in apps.Where(IsValid))
await Task.WhenAll(ids {
.SelectMany(x => x).Distinct() await CacheItAsync(app);
.Select(id => GetAppAsync(id, false, ct))); }
return apps.NotNull().ToList(); return apps.Where(IsValid).ToList();
} }
} }
@ -78,20 +81,25 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{ {
if (canCache) 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; 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 (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; return cachedApp;
} }
} }
var app = await GetAppCoreAsync(appId); var app = await appRepository.FindAsync(appId, ct);
if (!IsValid(app))
{
app = null;
}
if (app != null) if (app != null)
{ {
@ -119,57 +132,27 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
} }
} }
private async Task<IReadOnlyCollection<DomainId>> GetAppIdsByUserAsync(string userId) public async Task HandleAsync(CommandContext context, NextDelegate next,
{ CancellationToken ct)
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)
{ {
var command = context.Command; var command = context.Command;
if (command is CreateApp createApp) if (command is CreateApp createApp)
{ {
var cache = Cache(); var token = await CheckAppAsync(createApp, ct);
var token = await CheckAppAsync(cache, createApp);
try try
{ {
await next(context); await next(context, ct);
} }
finally finally
{ {
await cache.RemoveReservationAsync(token); // Always remove the reservation and therefore do not pass over cancellation token.
await namesState.RemoveReservationAsync(token, default);
} }
} }
else else
{ {
await next(context); await next(context, ct);
} }
if (context.IsCompleted) 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) if (token == null)
{ {
@ -204,15 +188,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
private async Task OnCreateAsync(CreateApp create) private async Task OnCreateAsync(CreateApp create)
{ {
await InvalidateItAsync(create.AppId, create.Name); await InvalidateItAsync(create.AppId, create.Name);
await Cache().AddAsync(create.AppId, create.Name);
} }
private async Task OnDeleteAsync(DeleteApp delete) private async Task OnDeleteAsync(DeleteApp delete)
{ {
await InvalidateItAsync(delete.AppId.Id, delete.AppId.Name); await InvalidateItAsync(delete.AppId.Id, delete.AppId.Name);
await Cache().RemoveAsync(delete.AppId.Id);
} }
private async Task OnUpdateAsync(AppUpdateCommand update) 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); 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) private static string GetCacheKey(DomainId id)
{ {
return $"{typeof(AppsIndex)}_Apps_Id_{id}"; return $"{typeof(AppsIndex)}_Apps_Id_{id}";
@ -247,9 +210,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
return $"{typeof(AppsIndex)}_Apps_Name_{name}"; 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) private Task InvalidateItAsync(DomainId id, string name)
{ {
return grainCache.RemoveAsync( return appCache.RemoveAsync(
GetCacheKey(id), GetCacheKey(id),
GetCacheKey(name)); GetCacheKey(name));
} }
@ -257,8 +225,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
private Task CacheItAsync(IAppEntity app) private Task CacheItAsync(IAppEntity app)
{ {
return Task.WhenAll( return Task.WhenAll(
grainCache.AddAsync(GetCacheKey(app.Id), app, CacheDuration), appCache.AddAsync(GetCacheKey(app.Id), app, CacheDuration),
grainCache.AddAsync(GetCacheKey(app.Name), 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, Task<string?> ReserveAsync(DomainId id, string name,
CancellationToken ct = default); CancellationToken ct = default);
Task RegisterAsync(DomainId id, string name,
CancellationToken ct = default);
Task RemoveReservationAsync(string? token, Task RemoveReservationAsync(string? token,
CancellationToken ct = default); 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; 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)) if (context.Command is AssignContributor assignContributor && ShouldResolve(assignContributor))
{ {
@ -31,11 +32,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
if (assignContributor.Invite) if (assignContributor.Invite)
{ {
(user, created) = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true); (user, created) = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true, ct);
} }
else else
{ {
user = await userResolver.FindByIdOrEmailAsync(assignContributor.ContributorId); user = await userResolver.FindByIdOrEmailAsync(assignContributor.ContributorId, ct);
} }
if (user != null) if (user != null)
@ -43,7 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
assignContributor.ContributorId = user.Id; assignContributor.ContributorId = user.Id;
} }
await next(context); await next(context, ct);
if (created && context.PlainResult is IAppEntity app) if (created && context.PlainResult is IAppEntity app)
{ {
@ -52,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
} }
else 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; } 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; using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Apps.Plans 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; 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()); 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); 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; 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) if (usageOptions.MaximumNumberOfApps <= 0 || context.Command is not CreateApp createApp || createApp.Actor.IsClient)
{ {
await next(context); await next(context, ct);
return; return;
} }
var totalApps = 0; var totalApps = 0;
var user = await userResolver.FindByIdAsync(createApp.Actor.Identifier); var user = await userResolver.FindByIdAsync(createApp.Actor.Identifier, ct);
if (user != null) 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) if (context.IsCompleted && user != null)
{ {
var newApps = totalApps + 1; var newApps = totalApps + 1;
var newAppsValue = newApps.ToString(CultureInfo.InvariantCulture); 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.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Orleans;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.UsageTracking; using Squidex.Infrastructure.UsageTracking;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Apps.Plans 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 MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IAppPlansProvider appPlansProvider; private readonly IAppPlansProvider appPlansProvider;
private readonly IApiUsageTracker apiUsageTracker; 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.appPlansProvider = appPlansProvider;
this.apiUsageTracker = apiUsageTracker; 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); 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 (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, AppId = appId,
AppName = app.Name, AppName = app.Name,
Usage = usage, Usage = usage,
UsageLimit = limit, UsageLimit = blockLimit,
Users = GetUsers(app) Users = GetUsers(app)
}; };
GetGrain().NotifyAsync(notification).Forget(); await messaging.PublishAsync(notification, ct: ct);
TrackNotified(appId); 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() blocking = usage >= client.ApiCallsLimit;
{ }
return grainFactory.GetGrain<IUsageNotifierGrain>(SingleGrain.Id); }
return blocking;
} }
private bool HasNotifiedBefore(DomainId appId) 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 NodaTime;
using Orleans.Core;
using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Tasks;
using Squidex.Messaging;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Plans 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 static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromDays(3);
private readonly IGrainState<State> grainState; private readonly SimpleState<State> state;
private readonly INotificationSender notificationSender; private readonly INotificationSender notificationSender;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
private readonly IClock clock;
[CollectionName("UsageNotifications")] [CollectionName("UsageNotifications")]
public sealed class State 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 Dictionary<DomainId, DateTime> NotificationsSent { get; } = new Dictionary<DomainId, DateTime>();
} }
public UsageNotifierGrain(IGrainIdentity identity, public IClock Clock { get; set; } = SystemClock.Instance;
IGrainState<State> grainState, INotificationSender notificationSender, IUserResolver userResolver, IClock clock)
: base(identity) public UsageNotifierWorker(IPersistenceFactory<State> persistenceFactory,
INotificationSender notificationSender, IUserResolver userResolver)
{ {
this.grainState = grainState;
this.notificationSender = notificationSender; this.notificationSender = notificationSender;
this.userResolver = userResolver; 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) if (!notificationSender.IsActive)
{ {
return; return;
} }
var now = clock.GetCurrentInstant().ToDateTimeUtc(); var now = Clock.GetCurrentInstant().ToDateTimeUtc();
if (!HasBeenSentBefore(notification.AppId, now)) if (!HasBeenSentBefore(notification.AppId, now))
{ {
@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
{ {
foreach (var userId in notification.Users) foreach (var userId in notification.Users)
{ {
var user = await userResolver.FindByIdOrEmailAsync(userId); var user = await userResolver.FindByIdOrEmailAsync(userId, ct);
if (user != null) if (user != null)
{ {
@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
private bool HasBeenSentBefore(DomainId appId, DateTime now) 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; var elapsed = now - lastSent;
@ -85,9 +85,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
private Task TrackNotifiedAsync(DomainId appId, DateTime now) 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 public interface IAppRepository
{ {
Task<Dictionary<string, DomainId>> QueryIdsAsync(string contributorId, Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names,
CancellationToken ct = default); 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); 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; 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) 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( var asset = await assetLoader.GetAsync(
assetEvent.AppId.Id, assetEvent.AppId.Id,
assetEvent.AssetId, assetEvent.AssetId,
@event.Headers.EventStreamNumber()); @event.Headers.EventStreamNumber(),
ct);
if (asset != null) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans; using Squidex.Hosting;
using Orleans.Runtime; using Squidex.Infrastructure.Timers;
using tusdotnet.Interfaces; using tusdotnet.Interfaces;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public sealed class AssetCleanupGrain : Grain, IRemindable, IAssetCleanupGrain public sealed class AssetCleanupProcess : IBackgroundProcess
{ {
private readonly ITusExpirationStore expirationStore; private readonly ITusExpirationStore expirationStore;
private CompletionTimer timer;
public AssetCleanupGrain(ITusExpirationStore expirationStore) public AssetCleanupProcess(ITusExpirationStore expirationStore)
{ {
this.expirationStore = 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, public Task DeleteAppAsync(IAppEntity app,
CancellationToken ct) 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 System.Security.Cryptography;
using Orleans;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -14,7 +13,7 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject 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 IAssetFileStore assetFileStore;
private readonly IAssetEnricher assetEnricher; private readonly IAssetEnricher assetEnricher;
@ -23,13 +22,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
private readonly IEnumerable<IAssetMetadataSource> assetMetadataSources; private readonly IEnumerable<IAssetMetadataSource> assetMetadataSources;
public AssetCommandMiddleware( public AssetCommandMiddleware(
IGrainFactory grainFactory, IDomainObjectFactory domainObjectFactory,
IDomainObjectCache domainObjectCache,
IAssetEnricher assetEnricher, IAssetEnricher assetEnricher,
IAssetFileStore assetFileStore, IAssetFileStore assetFileStore,
IAssetQueryService assetQuery, IAssetQueryService assetQuery,
IContextProvider contextProvider, IContextProvider contextProvider,
IEnumerable<IAssetMetadataSource> assetMetadataSources) IEnumerable<IAssetMetadataSource> assetMetadataSources)
: base(grainFactory) : base(domainObjectFactory, domainObjectCache)
{ {
this.assetEnricher = assetEnricher; this.assetEnricher = assetEnricher;
this.assetFileStore = assetFileStore; this.assetFileStore = assetFileStore;
@ -38,39 +38,41 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
this.contextProvider = contextProvider; 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) switch (context.Command)
{ {
case CreateAsset create: case CreateAsset create:
await UploadWithDuplicateCheckAsync(context, create, create.Duplicate, next); await UploadWithDuplicateCheckAsync(context, create, create.Duplicate, next, ct);
break; break;
case UpsertAsset upsert: case UpsertAsset upsert:
await UploadWithDuplicateCheckAsync(context, upsert, upsert.Duplicate, next); await UploadWithDuplicateCheckAsync(context, upsert, upsert.Duplicate, next, ct);
break; break;
case MoveAsset move: case MoveAsset move:
await base.HandleAsync(context, next); await base.HandleAsync(context, next, ct);
break; break;
case UpdateAsset upload: case UpdateAsset upload:
await UploadAndHandleAsync(context, upload, next); await UploadAndHandleAsync(context, upload, next, ct);
break; break;
default: default:
await base.HandleAsync(context, next); await base.HandleAsync(context, next, ct);
break; 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(); var tempFile = context.ContextId.ToString();
try try
{ {
await EnrichWithHashAndUploadAsync(command, tempFile); await EnrichWithHashAndUploadAsync(command, tempFile, ct);
if (!duplicate) if (!duplicate)
{ {
@ -78,51 +80,54 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
await assetQuery.FindByHashAsync(contextProvider.Context, await assetQuery.FindByHashAsync(contextProvider.Context,
command.FileHash, command.FileHash,
command.File.FileName, command.File.FileName,
command.File.FileSize); command.File.FileSize,
ct);
if (existing != null) if (existing != null)
{ {
context.Complete(new AssetDuplicate(existing)); context.Complete(new AssetDuplicate(existing));
await next(context); await next(context, ct);
return; return;
} }
} }
await EnrichWithMetadataAsync(command); await EnrichWithMetadataAsync(command, ct);
await base.HandleAsync(context, next); await base.HandleAsync(context, next, ct);
} }
finally finally
{ {
await assetFileStore.DeleteAsync(tempFile); await assetFileStore.DeleteAsync(tempFile, ct);
await command.File.DisposeAsync(); 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(); var tempFile = context.ContextId.ToString();
try try
{ {
await EnrichWithHashAndUploadAsync(command, tempFile); await EnrichWithHashAndUploadAsync(command, tempFile, ct);
await EnrichWithMetadataAsync(command); await EnrichWithMetadataAsync(command, ct);
await base.HandleAsync(context, next); await base.HandleAsync(context, next, ct);
} }
finally finally
{ {
await assetFileStore.DeleteAsync(tempFile); await assetFileStore.DeleteAsync(tempFile, ct);
await command.File.DisposeAsync(); 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) if (payload is IAssetEntity asset)
{ {
@ -132,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
try 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) catch (AssetAlreadyExistsException) when (context.Command is not UpsertAsset)
{ {
@ -142,20 +147,21 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
if (payload is not IEnrichedAssetEntity) if (payload is not IEnrichedAssetEntity)
{ {
payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, default); payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, ct);
} }
} }
return payload; 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()) await using (var uploadStream = command.File.OpenRead())
{ {
using (var hashStream = new HasherStream(uploadStream, HashAlgorithmName.SHA256)) 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); command.FileHash = ComputeHash(command.File, hashStream);
} }
@ -169,11 +175,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
return $"{steamHash}{file.FileName}{file.FileSize}".ToSha256Base64(); 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) 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 namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{ {
public sealed partial class AssetDomainObject public partial class AssetDomainObject
{ {
public sealed class State : DomainObjectState<State>, IAssetEntity 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 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; private readonly IServiceProvider serviceProvider;
@ -29,8 +29,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
: base(id, persistence, log) : base(id, persistence, log)
{ {
this.serviceProvider = serviceProvider; this.serviceProvider = serviceProvider;
Capacity = 2;
} }
protected override bool IsDeleted(State snapshot) protected override bool IsDeleted(State snapshot)
@ -60,12 +58,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Equals(assetCommand.AssetId, Snapshot.Id); Equals(assetCommand.AssetId, Snapshot.Id);
} }
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command) public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{ {
switch (command) switch (command)
{ {
case UpsertAsset upsert: case UpsertAsset upsert:
return UpsertReturnAsync(upsert, async c => return UpsertReturnAsync(upsert, async (c, ct) =>
{ {
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -84,14 +83,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
} }
return Snapshot; return Snapshot;
}); }, ct);
case CreateAsset c: case CreateAsset create:
return CreateReturnAsync(c, async create => return CreateReturnAsync(create, async (c, ct) =>
{ {
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await CreateCore(create, operation); await CreateCore(c, operation);
if (Is.Change(Snapshot.ParentId, c.ParentId)) if (Is.Change(Snapshot.ParentId, c.ParentId))
{ {
@ -99,53 +98,54 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
} }
return Snapshot; return Snapshot;
}); }, ct);
case AnnotateAsset c: case AnnotateAsset annotate:
return UpdateReturnAsync(c, async c => return UpdateReturnAsync(annotate, async (c, ct) =>
{ {
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await AnnotateCore(c, operation); await AnnotateCore(c, operation);
return Snapshot; return Snapshot;
}); }, ct);
case UpdateAsset update: case UpdateAsset update:
return UpdateReturnAsync(update, async c => return UpdateReturnAsync(update, async (c, ct) =>
{ {
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await UpdateCore(c, operation); await UpdateCore(c, operation);
return Snapshot; return Snapshot;
}); }, ct);
case MoveAsset move: case MoveAsset move:
return UpdateReturnAsync(move, async c => return UpdateReturnAsync(move, async (c, ct) =>
{ {
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await MoveCore(c, operation); await MoveCore(c, operation);
return Snapshot; return Snapshot;
}); }, ct);
case DeleteAsset delete when delete.Permanent: 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); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await DeleteCore(c, operation); await DeleteCore(c, operation);
}); }, ct);
case DeleteAsset delete: case DeleteAsset delete:
return UpdateAsync(delete, async c => return UpdateAsync(delete, async (c, ct) =>
{ {
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);
await DeleteCore(c, operation); await DeleteCore(c, operation);
}); }, ct);
default: default:
ThrowHelper.NotSupportedException(); ThrowHelper.NotSupportedException();
return default!; 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); Equals(assetFolderCommand.AssetFolderId, Snapshot.Id);
} }
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command) public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{ {
switch (command) switch (command)
{ {
case CreateAssetFolder c: case CreateAssetFolder create:
return CreateReturnAsync(c, async create => return CreateReturnAsync(create, async (c, ct) =>
{ {
await CreateCore(create, c); await CreateCore(c, c);
return Snapshot; return Snapshot;
}); }, ct);
case MoveAssetFolder move: case MoveAssetFolder move:
return UpdateReturnAsync(move, async c => return UpdateReturnAsync(move, async (c, ct) =>
{ {
await MoveCore(c); await MoveCore(c);
return Snapshot; return Snapshot;
}); }, ct);
case RenameAssetFolder rename: case RenameAssetFolder rename:
return UpdateReturnAsync(rename, async c => return UpdateReturnAsync(rename, async (c, ct) =>
{ {
await RenameCore(c); await RenameCore(c);
return Snapshot; return Snapshot;
}); }, ct);
case DeleteAssetFolder delete: case DeleteAssetFolder delete:
return Update(delete, c => return Update(delete, c =>
{ {
Delete(c); Delete(c);
}); }, ct);
default: default:
ThrowHelper.NotSupportedException(); 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 IContextProvider contextProvider;
private readonly ILogger<AssetsBulkUpdateCommandMiddleware> log; 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( private sealed record BulkTask(
ICommandBus Bus, ICommandBus Bus,
int JobIndex, int JobIndex,
BulkUpdateJob CommandJob, BulkUpdateJob CommandJob,
BulkUpdateAssets Command, BulkUpdateAssets Command,
ConcurrentBag<BulkUpdateResultItem> Results ConcurrentBag<BulkUpdateResultItem> Results,
) CancellationToken CancellationToken);
{
}
public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider, ILogger<AssetsBulkUpdateCommandMiddleware> log) public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider, ILogger<AssetsBulkUpdateCommandMiddleware> log)
{ {
this.contextProvider = contextProvider; this.contextProvider = contextProvider;
this.log = log; 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) if (context.Command is BulkUpdateAssets bulkUpdates)
{ {
@ -103,7 +102,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
i, i,
bulkUpdates.Jobs[i], bulkUpdates.Jobs[i],
bulkUpdates, bulkUpdates,
results); results,
ct);
if (!await createCommandsBlock.SendAsync(task)) if (!await createCommandsBlock.SendAsync(task))
{ {
@ -124,16 +124,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
} }
else else
{ {
await next(context); await next(context, ct);
} }
} }
private async Task ExecuteCommandAsync(BulkTaskCommand bulkCommand) private async Task ExecuteCommandAsync(BulkTaskCommand bulkCommand)
{ {
var (task, id, command) = bulkCommand; var (task, id, command, ct) = bulkCommand;
try try
{ {
await task.Bus.PublishAsync(command); await task.Bus.PublishAsync(command, ct);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex)); task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex));
} }
@ -157,7 +157,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
command.AssetId = id; command.AssetId = id;
return new BulkTaskCommand(task, id, command); return new BulkTaskCommand(task, id, command, task.CancellationToken);
} }
catch (Exception ex) 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 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 sealed class FileTypeAssetMetadataSource : IAssetMetadataSource
{ {
public Task EnhanceAsync(UploadAssetCommand command) public Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{ {
if (command.Tags != null) 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 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; int Order => 0;
Task EnhanceAsync(UploadAssetCommand command); Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct);
IEnumerable<string> Format(IAssetEntity asset); 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; 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) 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()) await using (var uploadStream = command.File.OpenRead())
{ {
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, mimeType); imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, mimeType, ct);
} }
if (imageInfo != null) if (imageInfo != null)
@ -45,13 +46,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
await using (var tempStream = tempFile.OpenWrite()) 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()) 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(); 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 IEnumerable<IAssetMetadataSource> assetMetadataSources;
private readonly IRequestCache requestCache; private readonly IRequestCache requestCache;
private readonly IUrlGenerator urlGenerator; private readonly IUrlGenerator urlGenerator;
private readonly IJsonSerializer jsonSerializer; private readonly IJsonSerializer serializer;
public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources, IRequestCache requestCache, public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources, IRequestCache requestCache,
IUrlGenerator urlGenerator, IJsonSerializer jsonSerializer) IUrlGenerator urlGenerator, IJsonSerializer serializer)
{ {
this.tagService = tagService; this.tagService = tagService;
this.assetMetadataSources = assetMetadataSources; this.assetMetadataSources = assetMetadataSources;
this.requestCache = requestCache; this.requestCache = requestCache;
this.urlGenerator = urlGenerator; this.urlGenerator = urlGenerator;
this.jsonSerializer = jsonSerializer; this.serializer = serializer;
} }
public async Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset, Context context, public async Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset, Context context,
@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
u = url u = url
}; };
var json = jsonSerializer.Serialize(token); var json = serializer.Serialize(token);
asset.EditToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); asset.EditToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
} }
@ -136,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var tagsById = await CalculateTags(group); var tagsById = await CalculateTagsAsync(group, ct);
foreach (var asset in group) 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(); 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets.Queries namespace Squidex.Domain.Apps.Entities.Assets.Queries
{ {
public sealed class AssetLoader : IAssetLoader 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()); private async Task<AssetDomainObject.State?> GetCachedAsync(DomainId uniqueId, long version,
var assetState = await assetGrain.GetStateAsync(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)) private async Task<AssetDomainObject.State> GetAsync(DomainId uniqueId, long version,
{ CancellationToken ct)
return null; {
} 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 QueryModel queryModel = AssetQueryModel.Build();
private readonly IEdmModel edmModel; private readonly IEdmModel edmModel;
private readonly IJsonSerializer jsonSerializer; private readonly IJsonSerializer serializer;
private readonly ITagService tagService; private readonly ITagService tagService;
private readonly AssetOptions options; 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.tagService = tagService;
this.options = options.Value; this.options = options.Value;
edmModel = queryModel.ConvertToEdm("Squidex", "Asset"); 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(context);
Guard.NotNull(q); Guard.NotNull(q);
@ -46,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{ {
var query = ParseClrQuery(q); var query = ParseClrQuery(q);
await TransformTagAsync(context, query); await TransformTagAsync(context, query, ct);
WithSorting(query); WithSorting(query);
WithPaging(query, q); 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) 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) private ClrQuery ParseJson(string json)
{ {
return queryModel.Parse(json, jsonSerializer); return queryModel.Parse(json, serializer);
} }
private ClrQuery ParseOData(string odata) 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")) 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); 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(); 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() 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); 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) 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)) 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; command.Actor = folderDeleted.Actor;
await commandBus.PublishAsync(command); await commandBus.PublishAsync(command, default);
} }
catch (Exception ex) 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 private const int FileSizeLimit = 2 * 1024 * 1024; // 2MB
public async Task EnhanceAsync(UploadAssetCommand command) public async Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct)
{ {
var isSvg = var isSvg =
command.File.MimeType == "image/svg+xml" || 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; 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) [EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(eventFormatter); Guard.NotNull(eventFormatter);
Guard.NotNull(eventStreams); Guard.NotNull(eventStreamNames);
while (!ct.IsCancellationRequested) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Backup namespace Squidex.Domain.Apps.Entities.Backup
{ {
public sealed class BackupService : IBackupService, IDeleter 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, Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) 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, public Task DeleteBackupAsync(DomainId appId, DomainId backupId,
CancellationToken ct = default) 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) 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) 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, public async Task<IBackupJob?> GetBackupAsync(DomainId appId, DomainId backupId,
CancellationToken ct = default) 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) await state.LoadAsync(ct);
{
return grainFactory.GetGrain<IBackupGrain>(appId.ToString()); 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); 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, Task<bool> HasFileAsync(string name,
CancellationToken ct = default); 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); 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 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( Task<IRestoreJob> GetRestoreAsync(
CancellationToken ct = default); 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Infrastructure; 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 namespace Squidex.Domain.Apps.Entities.Backup
{ {
public interface IRestoreGrain : IGrainWithStringKey public sealed record BackupRestore(RefToken Actor, Uri Url, string? NewAppName = null);
{
Task RestoreAsync(Uri url, RefToken actor, 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 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Entities.Backup.State namespace Squidex.Domain.Apps.Entities.Backup.State
{ {
public sealed class BackupState public sealed class BackupState
{ {
public List<BackupJob> Jobs { get; } = new List<BackupJob>(); 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 NodaTime;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Entities.Backup.State namespace Squidex.Domain.Apps.Entities.Backup.State
{ {
@ -31,5 +32,13 @@ namespace Squidex.Domain.Apps.Entities.Backup.State
public JobStatus Status { get; set; } public JobStatus Status { get; set; }
public string? NewAppName { 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] [ExcludeFromCodeCoverage]
public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation 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; Stream stream;
@ -40,12 +41,12 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
client.Timeout = TimeSpan.FromHours(1); client.Timeout = TimeSpan.FromHours(1);
response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode(); 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 try
{ {
return new BackupReader(jsonSerializer, stream); return new BackupReader(serializer, stream);
} }
catch (IOException) catch (IOException)
{ {
@ -94,9 +95,10 @@ namespace Squidex.Domain.Apps.Entities.Backup
return fileStream; 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); 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;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments.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 NamedId<DomainId> AppId { get; set; }
public DomainId CommentsId { get; set; } public DomainId CommentsId { get; set; }
public DomainId CommentId { 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans;
using Squidex.Domain.Apps.Entities.Comments.DomainObject; using Squidex.Domain.Apps.Entities.Comments.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments namespace Squidex.Domain.Apps.Entities.Comments
{ {
public sealed class CommentsLoader : ICommentsLoader 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 System.Text.RegularExpressions;
using Orleans;
using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject 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 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; private readonly IUserResolver userResolver;
public CommentsCommandMiddleware(IGrainFactory grainFactory, IUserResolver userResolver) public CommentsCommandMiddleware(IDomainObjectFactory domainObjectFactory, IUserResolver userResolver)
: base(domainObjectFactory)
{ {
this.grainFactory = grainFactory;
this.userResolver = userResolver; 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) if (context.Command is CommentsCommand commentsCommand)
{ {
@ -34,21 +32,9 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{ {
await MentionUsersAsync(createComment); await MentionUsersAsync(createComment);
} }
await ExecuteCommandAsync(commentsCommand);
} }
await next(context); await base.HandleAsync(context, next, ct);
}
private Task ExecuteCommandAsync(CommentsCommand commentsCommand)
{
return GetGrain(commentsCommand).ExecuteAsync(commentsCommand);
}
private ICommentsGrain GetGrain(CommentsCommand commentsCommand)
{
return grainFactory.GetGrain<ICommentsGrain>(commentsCommand.CommentsId.ToString());
} }
private static bool IsMention(CreateComment createComment) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Orleans.Core;
using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards; using Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
using Squidex.Domain.Apps.Events.Comments; using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
#pragma warning disable MA0022 // Return Task.FromResult instead of returning null #pragma warning disable MA0022 // Return Task.FromResult instead of returning null
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject 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>> uncommittedEvents = new List<Envelope<CommentsEvent>>();
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>(); private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>();
private readonly DomainId key;
private readonly IEventFormatter eventFormatter; private readonly IEventFormatter eventFormatter;
private readonly IEventStore eventStore; private readonly IEventStore eventStore;
private long version = EtagVersion.Empty; private long version = EtagVersion.Empty;
@ -33,20 +32,22 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
get => version; get => version;
} }
public CommentsGrain(IGrainIdentity identity, public CommentsStream(
DomainId key,
IEventFormatter eventFormatter, IEventFormatter eventFormatter,
IEventStore eventStore) IEventStore eventStore)
: base(identity)
{ {
this.key = key;
this.eventFormatter = eventFormatter; this.eventFormatter = eventFormatter;
this.eventStore = eventStore; 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) 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) switch (command)
{ {
@ -68,23 +70,23 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
GuardComments.CanCreate(c); GuardComments.CanCreate(c);
Create(c); Create(c);
}); }, ct);
case UpdateComment updateComment: case UpdateComment updateComment:
return Upsert(updateComment, c => return Upsert(updateComment, c =>
{ {
GuardComments.CanUpdate(c, Key.ToString(), events); GuardComments.CanUpdate(c, key.ToString(), events);
Update(c); Update(c);
}); }, ct);
case DeleteComment deleteComment: case DeleteComment deleteComment:
return Upsert(deleteComment, c => return Upsert(deleteComment, c =>
{ {
GuardComments.CanDelete(c, Key.ToString(), events); GuardComments.CanDelete(c, key.ToString(), events);
Delete(c); Delete(c);
}); }, ct);
default: default:
ThrowHelper.NotSupportedException(); 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(command);
Guard.NotNull(handler); Guard.NotNull(handler);
if (command.ExpectedVersion > EtagVersion.Any && command.ExpectedVersion != Version) 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; var previousVersion = version;
@ -114,12 +117,12 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
var eventData = uncommittedEvents.Select(x => eventFormatter.ToEventData(x, commitId)).ToList(); 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); events.AddRange(uncommittedEvents);
return CommandResult.Empty(Key, Version, previousVersion); return CommandResult.Empty(key, Version, previousVersion);
} }
catch catch
{ {
@ -155,14 +158,14 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
version++; version++;
} }
public List<Envelope<CommentsEvent>> GetUncommittedEvents() public virtual List<Envelope<CommentsEvent>> GetUncommittedEvents()
{ {
return uncommittedEvents; 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 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 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( await contentLoader.GetAsync(
contentEvent.AppId.Id, contentEvent.AppId.Id,
contentEvent.ContentId, contentEvent.ContentId,
@event.Headers.EventStreamNumber()); @event.Headers.EventStreamNumber(),
ct);
if (content != null) if (content != null)
{ {
@ -117,7 +118,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
await contentLoader.GetAsync( await contentLoader.GetAsync(
content.AppId.Id, content.AppId.Id,
content.Id, content.Id,
content.Version - 1); content.Version - 1,
ct);
if (previousContent != null) if (previousContent != null)
{ {

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

Loading…
Cancel
Save