diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 743ebd43d..71ab5c4f9 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -614,6 +614,7 @@ "rules.ruleSyntax.then": "then", "rules.run": "Run", "rules.runFailed": "Failed to run rule. Please reload.", + "rules.runFromSnapshots": "Run with latest states", "rules.runningRule": "Rule '{name}' is currently running.", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmTitle": "Run rule", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index efb9ce2c8..fa93e54b2 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -614,6 +614,7 @@ "rules.ruleSyntax.then": "then", "rules.run": "Esegui", "rules.runFailed": "Non è stato possibile eseguire la regola. Per favore ricarica.", + "rules.runFromSnapshots": "Run with latest states", "rules.runningRule": "La regola '{name}' è attualmente in esecuzione.", "rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?", "rules.runRuleConfirmTitle": "Esegui la regola", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index a12ec2bcc..f9adc2599 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -614,6 +614,7 @@ "rules.ruleSyntax.then": "then", "rules.run": "Uitvoeren", "rules.runFailed": "Uitvoeren van regel mislukt. Laad opnieuw.", + "rules.runFromSnapshots": "Run with latest states", "rules.runningRule": "Regel '{name}' is momenteel actief.", "rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?", "rules.runRuleConfirmTitle": "Regel uitvoeren", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 743ebd43d..71ab5c4f9 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -614,6 +614,7 @@ "rules.ruleSyntax.then": "then", "rules.run": "Run", "rules.runFailed": "Failed to run rule. Please reload.", + "rules.runFromSnapshots": "Run with latest states", "rules.runningRule": "Rule '{name}' is currently running.", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmTitle": "Run rule", diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs index fbddaf977..221ac18f8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs @@ -31,13 +31,18 @@ namespace Squidex.Domain.Apps.Core.HandleRules this.userResolver = userResolver; } - public async Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope @event) + public async Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope? @event) { - enrichedEvent.Timestamp = @event.Headers.Timestamp(); + if (@event != null) + { + enrichedEvent.Timestamp = @event.Headers.Timestamp(); + + enrichedEvent.AppId = @event.Payload.AppId; + } if (enrichedEvent is EnrichedUserEventBase userEvent) { - if (@event.Payload is SquidexEvent squidexEvent) + if (@event?.Payload is SquidexEvent squidexEvent) { userEvent.Actor = squidexEvent.Actor; } @@ -47,8 +52,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules userEvent.User = await FindUserAsync(userEvent.Actor); } } - - enrichedEvent.AppId = @event.Payload.AppId; } private Task FindUserAsync(RefToken actor) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs index 6580265ff..c803844bc 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs @@ -14,6 +14,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules { public interface IEventEnricher { - Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope @event); + Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope? @event); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs new file mode 100644 index 000000000..d6145e00f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public interface IRuleService + { + bool CanCreateSnapshotEvents(Rule rule); + + IAsyncEnumerable<(RuleJob? Job, Exception? Exception)> CreateSnapshotJobsAsync(Rule rule, DomainId ruleId, DomainId appId); + + Task> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope @event, bool ignoreStale = true); + + Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job); + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs index 81a51e6ed..d9906021b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs @@ -20,6 +20,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules { Type TriggerType { get; } + bool CanCreateSnapshotEvents { get; } + + IAsyncEnumerable CreateSnapshotEvents(RuleTrigger trigger, DomainId appId); + Task> CreateEnrichedEventsAsync(Envelope @event); bool Trigger(EnrichedEvent @event, RuleTrigger trigger); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index 0ac0d23d5..1813ccb1a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Options; using NodaTime; using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -20,11 +21,10 @@ using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Tasks; using Squidex.Log; -using JobList = System.Collections.Generic.List<(Squidex.Domain.Apps.Core.Rules.RuleJob Job, System.Exception? Exception)>; namespace Squidex.Domain.Apps.Core.HandleRules { - public class RuleService + public class RuleService : IRuleService { private readonly Dictionary ruleActionHandlers; private readonly Dictionary ruleTriggerHandlers; @@ -68,12 +68,74 @@ namespace Squidex.Domain.Apps.Core.HandleRules this.log = log; } - public virtual async Task CreateJobsAsync(Rule rule, DomainId ruleId, Envelope @event, bool ignoreStale = true) + public bool CanCreateSnapshotEvents(Rule rule) + { + Guard.NotNull(rule, nameof(rule)); + + if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) + { + return false; + } + + return triggerHandler.CanCreateSnapshotEvents; + } + + public async IAsyncEnumerable<(RuleJob? Job, Exception? Exception)> CreateSnapshotJobsAsync(Rule rule, DomainId ruleId, DomainId appId) + { + if (!rule.IsEnabled) + { + yield break; + } + + if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) + { + yield break; + } + + if (!ruleActionHandlers.TryGetValue(rule.Action.GetType(), out var actionHandler)) + { + yield break; + } + + if (!triggerHandler.CanCreateSnapshotEvents) + { + yield break; + } + + var now = clock.GetCurrentInstant(); + + await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEvents(rule.Trigger, appId)) + { + Exception? exception; + + RuleJob? job = null; + + try + { + await eventEnricher.EnrichAsync(enrichedEvent, null); + + if (!triggerHandler.Trigger(enrichedEvent, rule.Trigger)) + { + continue; + } + + (job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent); + } + catch (Exception ex) + { + exception = ex; + } + + yield return (job, exception); + } + } + + public virtual async Task> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope @event, bool ignoreStale = true) { Guard.NotNull(rule, nameof(rule)); Guard.NotNull(@event, nameof(@event)); - var result = new JobList(); + var result = new List<(RuleJob Job, Exception? Exception)>(); try { @@ -118,8 +180,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules return result; } - var expires = now.Plus(Constants.ExpirationTime); - if (!triggerHandler.Trigger(typed.Payload, rule.Trigger, ruleId)) { return result; @@ -140,39 +200,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules continue; } - var actionName = typeNameRegistry.GetName(actionType); - - var job = new RuleJob - { - Id = DomainId.NewGuid(), - ActionData = string.Empty, - ActionName = actionName, - AppId = enrichedEvent.AppId.Id, - Created = now, - EventName = enrichedEvent.Name, - ExecutionPartition = enrichedEvent.Partition, - Expires = expires, - RuleId = ruleId - }; - - try - { - var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); - - var json = jsonSerializer.Serialize(data); - - job.ActionData = json; - job.ActionName = actionName; - job.Description = description; - - result.Add((job, null)); - } - catch (Exception ex) - { - job.Description = "Failed to create job"; + var (job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent); - result.Add((job, ex)); - } + result.Add((job, exception)); } catch (Exception ex) { @@ -192,6 +222,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules return result; } + private async Task<(RuleJob, Exception?)> CreateJobAsync(Rule rule, DomainId ruleId, IRuleActionHandler actionHandler, Instant now, EnrichedEvent enrichedEvent) + { + var actionName = typeNameRegistry.GetName(rule.Action.GetType()); + + var expires = now.Plus(Constants.ExpirationTime); + + var job = new RuleJob + { + Id = DomainId.NewGuid(), + ActionData = string.Empty, + ActionName = actionName, + AppId = enrichedEvent.AppId.Id, + Created = now, + EventName = enrichedEvent.Name, + ExecutionPartition = enrichedEvent.Partition, + Expires = expires, + RuleId = ruleId + }; + + try + { + var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); + + var json = jsonSerializer.Serialize(data); + + job.ActionData = json; + job.ActionName = actionName; + job.Description = description; + + return (job, null); + } + catch (Exception ex) + { + job.Description = "Failed to create job"; + + return (job, ex); + } + } + public virtual async Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job) { var actionWatch = ValueStopwatch.StartNew(); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs index 2f3adea3e..c7aa5d7a6 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs @@ -28,6 +28,17 @@ namespace Squidex.Domain.Apps.Core.HandleRules get { return typeof(TTrigger); } } + public virtual bool CanCreateSnapshotEvents + { + get { return false; } + } + + public virtual async IAsyncEnumerable CreateSnapshotEvents(TTrigger trigger, DomainId appId) + { + await Task.Yield(); + yield break; + } + public virtual async Task> CreateEnrichedEventsAsync(Envelope @event) { var enrichedEvent = await CreateEnrichedEventAsync(@event.To()); @@ -45,6 +56,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules } } + IAsyncEnumerable IRuleTriggerHandler.CreateSnapshotEvents(RuleTrigger trigger, DomainId appId) + { + return CreateSnapshotEvents((TTrigger)trigger, appId); + } + bool IRuleTriggerHandler.Trigger(EnrichedEvent @event, RuleTrigger trigger) { if (@event is TEnrichedEvent typed) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 84150307c..19cfff710 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -66,6 +66,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets }, ct); } + public async IAsyncEnumerable StreamAll(DomainId appId) + { + var find = Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); + + using (var cursor = await find.ToCursorAsync()) + { + while (await cursor.MoveNextAsync()) + { + foreach (var entity in cursor.Current) + { + yield return entity; + } + } + } + } + public async Task> QueryAsync(DomainId appId, DomainId? parentId, ClrQuery query) { using (Profiler.TraceMethod("QueryAsyncByQuery")) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs index 42226946a..462633f0c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs @@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { queryContentAsync = new QueryContent(converter); queryContentsById = new QueryContentsByIds(converter, appProvider); - queryContentsByQuery = new QueryContentsByQuery(converter, indexer); + queryContentsByQuery = new QueryContentsByQuery(converter, indexer, appProvider); queryIdsAsync = new QueryIdsAsync(appProvider); queryReferrersAsync = new QueryReferrersAsync(); queryScheduledItems = new QueryScheduledContents(); @@ -65,6 +65,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await queryScheduledItems.PrepareAsync(collection, ct); } + public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds) + { + return queryContentsByQuery.StreamAll(appId, schemaIds); + } + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced) { using (Profiler.TraceMethod("QueryAsyncByQuery")) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs index 6911014e8..54907913e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs @@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { queryContentAsync = new QueryContent(converter); queryContentsById = new QueryContentsByIds(converter, appProvider); - queryContentsByQuery = new QueryContentsByQuery(converter, indexer); + queryContentsByQuery = new QueryContentsByQuery(converter, indexer, appProvider); queryIdsAsync = new QueryIdsAsync(appProvider); } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 3faf06966..915026a91 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -55,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collectionPublished.InitializeAsync(ct); } + public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds) + { + return collectionAll.StreamAll(appId, schemaIds); + } + public Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope) { if (scope == SearchScope.All) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs index 736dd22d9..796c7f15a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs @@ -27,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { private readonly DataConverter converter; private readonly ITextIndex indexer; + private readonly IAppProvider appProvider; [BsonIgnoreExtraElements] internal sealed class IdOnly @@ -38,11 +39,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public MongoContentEntity[] Joined { get; set; } } - public QueryContentsByQuery(DataConverter converter, ITextIndex indexer) + public QueryContentsByQuery(DataConverter converter, ITextIndex indexer, IAppProvider appProvider) { this.converter = converter; - this.indexer = indexer; + this.appProvider = appProvider; } protected override async Task PrepareAsync(CancellationToken ct = default) @@ -66,6 +67,32 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations await Collection.Indexes.CreateOneAsync(indexBySchema, cancellationToken: ct); } + public async IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds) + { + var find = + schemaIds != null ? + Collection.Find(x => x.IndexedAppId == appId && schemaIds.Contains(x.IndexedSchemaId) && !x.IsDeleted) : + Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); + + using (var cursor = await find.ToCursorAsync()) + { + while (await cursor.MoveNextAsync()) + { + foreach (var entity in cursor.Current) + { + var schema = await appProvider.GetSchemaAsync(appId, entity.SchemaId.Id, false); + + if (schema != null) + { + entity.ParseData(schema.SchemaDef, converter); + + yield return entity; + } + } + } + } + } + public async Task> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope) { Guard.NotNull(app, nameof(app)); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index c0fba1e7b..65952f99d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -95,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules return Collection.UpdateOneAsync(x => x.DocumentId == id, Update.Set(x => x.NextAttempt, nextAttempt)); } - public async Task EnqueueAsync(RuleJob job, Instant? nextAttempt, CancellationToken ct = default) + public async Task EnqueueAsync(RuleJob job, Instant? nextAttempt) { var entity = new MongoRuleEventEntity { Job = job, Created = job.Created, NextAttempt = nextAttempt }; @@ -103,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules entity.DocumentId = job.Id; - await Collection.InsertOneIfNotExistsAsync(entity, ct); + await Collection.InsertOneIfNotExistsAsync(entity); } public Task CancelAsync(DomainId id) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs index 9efdbbd8f..7fd0c7f85 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs @@ -5,11 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -21,15 +23,40 @@ namespace Squidex.Domain.Apps.Entities.Assets { private readonly IScriptEngine scriptEngine; private readonly IAssetLoader assetLoader; + private readonly IAssetRepository assetRepository; - public AssetChangedTriggerHandler(IScriptEngine scriptEngine, IAssetLoader assetLoader) + public override bool CanCreateSnapshotEvents => true; + + public AssetChangedTriggerHandler( + IScriptEngine scriptEngine, + IAssetLoader assetLoader, + IAssetRepository assetRepository) { Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(assetLoader, nameof(assetLoader)); + Guard.NotNull(assetRepository, nameof(assetRepository)); this.scriptEngine = scriptEngine; - this.assetLoader = assetLoader; + this.assetRepository = assetRepository; + } + + public override async IAsyncEnumerable CreateSnapshotEvents(AssetChangedTriggerV2 trigger, DomainId appId) + { + await foreach (var asset in assetRepository.StreamAll(appId)) + { + var result = new EnrichedAssetEvent + { + Type = EnrichedAssetEventType.Created + }; + + SimpleMapper.Map(asset, result); + + result.Actor = asset.LastModifiedBy; + result.Name = "AssetCreatedFromSnapshot"; + + yield return result; + } } protected override async Task CreateEnrichedEventAsync(Envelope @event) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index 884bea4e1..abe59cf72 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { + IAsyncEnumerable StreamAll(DomainId appId); + Task> QueryAsync(DomainId appId, DomainId? parentId, ClrQuery query); Task> QueryAsync(DomainId appId, HashSet ids); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index 972edf764..20fd1f04c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -5,12 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -23,14 +26,45 @@ namespace Squidex.Domain.Apps.Entities.Contents { private readonly IScriptEngine scriptEngine; private readonly IContentLoader contentLoader; + private readonly IContentRepository contentRepository; - public ContentChangedTriggerHandler(IScriptEngine scriptEngine, IContentLoader contentLoader) + public override bool CanCreateSnapshotEvents => true; + + public ContentChangedTriggerHandler( + IScriptEngine scriptEngine, + IContentLoader contentLoader, + IContentRepository contentRepository) { Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(contentLoader, nameof(contentLoader)); + Guard.NotNull(contentRepository, nameof(contentRepository)); this.scriptEngine = scriptEngine; this.contentLoader = contentLoader; + this.contentRepository = contentRepository; + } + + public override async IAsyncEnumerable CreateSnapshotEvents(ContentChangedTriggerV2 trigger, DomainId appId) + { + var schemaIds = + trigger.Schemas?.Count > 0 ? + trigger.Schemas.Select(x => x.SchemaId).Distinct().ToHashSet() : + null; + + await foreach (var content in contentRepository.StreamAll(appId, schemaIds)) + { + var result = new EnrichedContentEvent + { + Type = EnrichedContentEventType.Created + }; + + SimpleMapper.Map(content, result); + + result.Actor = content.LastModifiedBy; + result.Name = $"{content.SchemaId.Name.ToPascalCase()}CreatedFromSnapshot"; + + yield return result; + } } protected override async Task CreateEnrichedEventAsync(Envelope @event) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 399858954..b598dcf3f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories { public interface IContentRepository { + IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds); + Task> QueryAsync(IAppEntity app, HashSet ids, SearchScope scope); Task> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet ids, SearchScope scope); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs index c31d523fa..046014c8b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using NodaTime; +using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Infrastructure; @@ -17,9 +18,29 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories { public interface IRuleEventRepository { + async Task EnqueueAsync(RuleJob job, Exception? ex) + { + if (ex != null) + { + await EnqueueAsync(job, (Instant?)null); + + await UpdateAsync(job, new RuleJobUpdate + { + JobResult = RuleJobResult.Failed, + ExecutionResult = RuleResult.Failed, + ExecutionDump = ex.ToString(), + Finished = job.Created + }); + } + else + { + await EnqueueAsync(job, job.Created); + } + } + Task UpdateAsync(RuleJob job, RuleJobUpdate update); - Task EnqueueAsync(RuleJob job, Instant? nextAttempt, CancellationToken ct = default); + Task EnqueueAsync(RuleJob job, Instant? nextAttempt); Task EnqueueAsync(DomainId id, Instant nextAttempt); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs index 2f097d452..c969e4ed1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs @@ -25,12 +25,15 @@ namespace Squidex.Domain.Apps.Entities.Rules { private readonly ITargetBlock requestBlock; private readonly IRuleEventRepository ruleEventRepository; - private readonly RuleService ruleService; + private readonly IRuleService ruleService; private readonly ConcurrentDictionary executing = new ConcurrentDictionary(); private readonly IClock clock; private readonly ISemanticLog log; - public RuleDequeuerGrain(RuleService ruleService, IRuleEventRepository ruleEventRepository, ISemanticLog log, IClock clock) + public RuleDequeuerGrain( + IRuleService ruleService, + IRuleEventRepository ruleEventRepository, + ISemanticLog log, IClock clock) { Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); Guard.NotNull(ruleService, nameof(ruleService)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index d8cb81481..344f39f44 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -22,19 +22,20 @@ namespace Squidex.Domain.Apps.Entities.Rules public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer { private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10); + private readonly IMemoryCache cache; private readonly IRuleEventRepository ruleEventRepository; + private readonly IRuleService ruleService; private readonly IAppProvider appProvider; - private readonly IMemoryCache cache; private readonly ILocalCache localCache; - private readonly RuleService ruleService; public string Name { get { return GetType().Name; } } - public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, IRuleEventRepository ruleEventRepository, - RuleService ruleService) + public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, + IRuleEventRepository ruleEventRepository, + IRuleService ruleService) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(cache, nameof(cache)); @@ -59,22 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules foreach (var (job, ex) in jobs) { - if (ex != null) - { - await ruleEventRepository.EnqueueAsync(job, null); - - await ruleEventRepository.UpdateAsync(job, new RuleJobUpdate - { - JobResult = RuleJobResult.Failed, - ExecutionResult = RuleResult.Failed, - ExecutionDump = ex.ToString(), - Finished = job.Created - }); - } - else - { - await ruleEventRepository.EnqueueAsync(job, job.Created); - } + await ruleEventRepository.EnqueueAsync(job, ex); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs index 7b6fc5fee..4c1f59579 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs @@ -7,6 +7,8 @@ using System.Threading.Tasks; using Orleans; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Rules.Runner @@ -14,12 +16,15 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner public sealed class GrainRuleRunnerService : IRuleRunnerService { private readonly IGrainFactory grainFactory; + private readonly IRuleService ruleService; - public GrainRuleRunnerService(IGrainFactory grainFactory) + public GrainRuleRunnerService(IGrainFactory grainFactory, IRuleService ruleService) { Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(ruleService, nameof(ruleService)); this.grainFactory = grainFactory; + this.ruleService = ruleService; } public Task CancelAsync(DomainId appId) @@ -29,6 +34,16 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner return grain.CancelAsync(); } + public bool CanRunRule(IRuleEntity rule) + { + return rule.RuleDef.IsEnabled && rule.RuleDef.Trigger is not ManualTrigger; + } + + public bool CanRunFromSnapshots(IRuleEntity rule) + { + return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(rule.RuleDef); + } + public Task GetRunningRuleIdAsync(DomainId appId) { var grain = grainFactory.GetGrain(appId.ToString()); @@ -36,11 +51,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner return grain.GetRunningRuleIdAsync(); } - public Task RunAsync(DomainId appId, DomainId ruleId) + public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false) { var grain = grainFactory.GetGrain(appId.ToString()); - return grain.RunAsync(ruleId); + return grain.RunAsync(ruleId, fromSnapshots); } } } \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerGrain.cs index ba6dadc20..b61586cbb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerGrain.cs @@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { public interface IRuleRunnerGrain : IGrainWithStringKey { - Task RunAsync(DomainId ruleId); + Task RunAsync(DomainId ruleId, bool fromSnapshots); Task CancelAsync(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs index 87089a92e..d4f5651ae 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs @@ -12,10 +12,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { public interface IRuleRunnerService { - Task RunAsync(DomainId appId, DomainId ruleId); + Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false); Task CancelAsync(DomainId appId); + bool CanRunRule(IRuleEntity rule); + + bool CanRunFromSnapshots(IRuleEntity rule); + Task GetRunningRuleIdAsync(DomainId appId); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs index 1ee8d8aae..4ff82584f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Orleans; using Orleans.Runtime; +using Squidex.Caching; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Infrastructure; @@ -24,12 +25,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { public sealed class RuleRunnerGrain : GrainOfString, IRuleRunnerGrain, IRemindable { + private const int MaxErrors = 10; private readonly IGrainState state; private readonly IAppProvider appProvider; + private readonly ILocalCache localCache; private readonly IEventStore eventStore; private readonly IEventDataFormatter eventDataFormatter; private readonly IRuleEventRepository ruleEventRepository; - private readonly RuleService ruleService; + private readonly IRuleService ruleService; private readonly ISemanticLog log; private CancellationTokenSource? currentJobToken; private IGrainReminder? currentReminder; @@ -41,19 +44,23 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner public DomainId? RuleId { get; set; } public string? Position { get; set; } + + public bool FromSnapshots { get; set; } } public RuleRunnerGrain( IGrainState state, IAppProvider appProvider, + ILocalCache localCache, IEventStore eventStore, IEventDataFormatter eventDataFormatter, IRuleEventRepository ruleEventRepository, - RuleService ruleService, + IRuleService ruleService, ISemanticLog log) { Guard.NotNull(state, nameof(state)); Guard.NotNull(appProvider, nameof(appProvider)); + Guard.NotNull(localCache, nameof(localCache)); Guard.NotNull(eventStore, nameof(eventStore)); Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); @@ -62,6 +69,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner this.state = state; this.appProvider = appProvider; + this.localCache = localCache; this.eventStore = eventStore; this.eventDataFormatter = eventDataFormatter; this.ruleEventRepository = ruleEventRepository; @@ -71,9 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner protected override Task OnActivateAsync(string key) { - EnsureIsRunning(); - - return base.OnActivateAsync(key); + return EnsureIsRunningAsync(true); } public override Task OnDeactivateAsync() @@ -104,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner return Task.FromResult(state.Value.RuleId); } - public async Task RunAsync(DomainId ruleId) + public async Task RunAsync(DomainId ruleId, bool fromSnapshots) { if (currentJobToken != null) { @@ -113,21 +119,33 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner state.Value = new State { - RuleId = ruleId + RuleId = ruleId, + FromSnapshots = fromSnapshots }; - EnsureIsRunning(); + await EnsureIsRunningAsync(false); await state.WriteAsync(); } - private void EnsureIsRunning() + private async Task EnsureIsRunningAsync(bool continues) { - if (state.Value.RuleId.HasValue && currentJobToken == null) + var job = state.Value; + + if (job.RuleId.HasValue && currentJobToken == null) { - currentJobToken = new CancellationTokenSource(); + if (state.Value.FromSnapshots && continues) + { + state.Value = new State(); + + await state.WriteAsync(); + } + else + { + currentJobToken = new CancellationTokenSource(); - Process(state.Value, currentJobToken.Token); + Process(state.Value, currentJobToken.Token); + } } } @@ -136,7 +154,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner ProcessAsync(job, ct).Forget(); } - private async Task ProcessAsync(State job, CancellationToken ct) + private async Task ProcessAsync(State currentState, CancellationToken ct) { try { @@ -144,42 +162,24 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner var rules = await appProvider.GetRulesAsync(DomainId.Create(Key)); - var rule = rules.Find(x => x.Id == job.RuleId); + var rule = rules.Find(x => x.Id == currentState.RuleId); if (rule == null) { throw new InvalidOperationException("Cannot find rule."); } - await eventStore.QueryAsync(async storedEvent => + using (localCache.StartContext()) { - try - { - var @event = eventDataFormatter.ParseIfKnown(storedEvent); - - if (@event != null) - { - var jobs = await ruleService.CreateJobsAsync(rule.RuleDef, rule.Id, @event, false); - - foreach (var (job, _) in jobs) - { - await ruleEventRepository.EnqueueAsync(job, job.Created, ct); - } - } - } - catch (Exception ex) + if (currentState.FromSnapshots && ruleService.CanCreateSnapshotEvents(rule.RuleDef)) { - log.LogWarning(ex, w => w - .WriteProperty("action", "runRule") - .WriteProperty("status", "failedPartially3")); + await EnqueueFromSnapshotsAsync(rule); } - finally + else { - job.Position = storedEvent.EventPosition; + await EnqueueFromEventsAsync(currentState, rule, ct); } - - await state.WriteAsync(); - }, $"^([a-z]+)\\-{Key}", job.Position, ct); + } } catch (OperationCanceledException) { @@ -190,14 +190,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner log.LogError(ex, w => w .WriteProperty("action", "runRule") .WriteProperty("status", "failed") - .WriteProperty("ruleId", job.RuleId?.ToString())); + .WriteProperty("ruleId", currentState.RuleId?.ToString())); } finally { if (!isStopping) { - job.RuleId = null; - job.Position = null; + currentState.RuleId = null; + currentState.Position = null; await state.WriteAsync(); @@ -214,11 +214,77 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner } } - public Task ReceiveReminder(string reminderName, TickStatus status) + private async Task EnqueueFromSnapshotsAsync(IRuleEntity rule) { - EnsureIsRunning(); + var errors = 0; - return Task.CompletedTask; + await foreach (var (job, ex) in ruleService.CreateSnapshotJobsAsync(rule.RuleDef, rule.Id, rule.AppId.Id)) + { + if (job != null) + { + await ruleEventRepository.EnqueueAsync(job, ex); + } + else if (ex != null) + { + errors++; + + if (errors >= MaxErrors) + { + throw ex; + } + + log.LogWarning(ex, w => w + .WriteProperty("action", "runRule") + .WriteProperty("status", "failedPartially")); + } + } + } + + private async Task EnqueueFromEventsAsync(State currentState, IRuleEntity rule, CancellationToken ct) + { + var errors = 0; + + await eventStore.QueryAsync(async storedEvent => + { + try + { + var @event = eventDataFormatter.ParseIfKnown(storedEvent); + + if (@event != null) + { + var jobs = await ruleService.CreateJobsAsync(rule.RuleDef, rule.Id, @event, false); + + foreach (var (job, ex) in jobs) + { + await ruleEventRepository.EnqueueAsync(job, ex); + } + } + } + catch (Exception ex) + { + errors++; + + if (errors >= MaxErrors) + { + throw; + } + + log.LogWarning(ex, w => w + .WriteProperty("action", "runRule") + .WriteProperty("status", "failedPartially")); + } + finally + { + currentState.Position = storedEvent.EventPosition; + } + + await state.WriteAsync(); + }, $"^([a-z]+)\\-{Key}", currentState.Position, ct); + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + return EnsureIsRunningAsync(true); } } } diff --git a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs index 049814061..ebf208b22 100644 --- a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -8,11 +8,34 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously namespace Squidex.Infrastructure { public static class CollectionExtensions { + public static async Task> ToListAsync(this IAsyncEnumerable source) + { + var result = new List(); + + await foreach (var item in source) + { + result.Add(item); + } + + return result; + } + + public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + } + public static bool SetEquals(this IReadOnlyCollection source, IReadOnlyCollection other) { return source.Count == other.Count && source.Intersect(other).Count() == other.Count; diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index f5eb9810f..75763f401 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using Lazy; +using Squidex.Domain.Apps.Entities; using Squidex.Infrastructure; using Squidex.Infrastructure.Security; using P = Squidex.Shared.Permissions; @@ -176,13 +177,15 @@ namespace Squidex.Web public ApiController Controller { get; } - public PermissionSet Permissions { get; } + public PermissionSet Permissions => Context.Permissions; + + public Context Context { get; set; } public Resources(ApiController controller) { Controller = controller; - Permissions = controller.HttpContext.Context().Permissions; + Context = controller.HttpContext.Context(); } public string Url(Func action, object? values = null) where T : ApiController diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index a854a7ac6..cb10eb13e 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -158,7 +158,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var response = Deferred.AsyncResponse(() => { - return ContentsDto.FromContentsAsync(contents, Context, Resources, null, contentWorkflow); + return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); }); return Ok(response); @@ -187,7 +187,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var response = Deferred.AsyncResponse(() => { - return ContentsDto.FromContentsAsync(contents, Context, Resources, null, contentWorkflow); + return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); }); return Ok(response); @@ -218,9 +218,9 @@ namespace Squidex.Areas.Api.Controllers.Contents var contents = await contentQuery.QueryAsync(Context, name, CreateQuery(ids, q)); - var response = Deferred.AsyncResponse(async () => + var response = Deferred.AsyncResponse(() => { - return await ContentsDto.FromContentsAsync(contents, Context, Resources, schema, contentWorkflow); + return ContentsDto.FromContentsAsync(contents, Resources, schema, contentWorkflow); }); return Ok(response); @@ -250,9 +250,9 @@ namespace Squidex.Areas.Api.Controllers.Contents var contents = await contentQuery.QueryAsync(Context, name, query?.ToQuery() ?? Q.Empty); - var response = Deferred.AsyncResponse(async () => + var response = Deferred.AsyncResponse(() => { - return await ContentsDto.FromContentsAsync(contents, Context, Resources, schema, contentWorkflow); + return ContentsDto.FromContentsAsync(contents, Resources, schema, contentWorkflow); }); return Ok(response); @@ -280,7 +280,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { var content = await contentQuery.FindAsync(Context, name, id); - var response = ContentDto.FromContent(Context, content, Resources); + var response = ContentDto.FromContent(content, Resources); return Ok(response); } @@ -307,7 +307,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { var content = await contentQuery.FindAsync(Context, name, id, version); - var response = ContentDto.FromContent(Context, content, Resources); + var response = ContentDto.FromContent(content, Resources); return Ok(response.Data); } @@ -613,7 +613,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = ContentDto.FromContent(Context, result, Resources); + var response = ContentDto.FromContent(result, Resources); return response; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 43d7ff444..19e1939a7 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -10,7 +10,6 @@ using NodaTime; using Squidex.Areas.Api.Controllers.Schemas.Models; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; -using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; @@ -104,11 +103,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// public long Version { get; set; } - public static ContentDto FromContent(Context context, IEnrichedContentEntity content, Resources resources) + public static ContentDto FromContent(IEnrichedContentEntity content, Resources resources) { var response = SimpleMapper.Map(content, new ContentDto()); - if (context.ShouldFlatten()) + if (resources.Context.ShouldFlatten()) { response.Data = content.Data.ToFlatten(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index 83d8aa4c2..74b42b5ce 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading.Tasks; -using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; @@ -35,13 +34,13 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models [LocalizedRequired] public StatusInfoDto[] Statuses { get; set; } - public static async Task FromContentsAsync(IResultList contents, Context context, Resources resources, + public static async Task FromContentsAsync(IResultList contents, Resources resources, ISchemaEntity? schema, IContentWorkflow workflow) { var result = new ContentsDto { Total = contents.Total, - Items = contents.Select(x => ContentDto.FromContent(context, x, resources)).ToArray() + Items = contents.Select(x => ContentDto.FromContent(x, resources)).ToArray() }; if (schema != null) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index 7ac68cf54..839d63b4d 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -10,6 +10,7 @@ using NodaTime; using Squidex.Areas.Api.Controllers.Rules.Models.Converters; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.Runner; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Validation; @@ -89,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// public Instant? LastExecuted { get; set; } - public static RuleDto FromRule(IEnrichedRuleEntity rule, DomainId? runningRuleId, Resources resources) + public static RuleDto FromRule(IEnrichedRuleEntity rule, bool canRun, IRuleRunnerService ruleRunnerService, Resources resources) { var result = new RuleDto(); @@ -101,10 +102,10 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models result.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger); } - return result.CreateLinks(resources, runningRuleId); + return result.CreateLinks(resources, rule, canRun, ruleRunnerService); } - private RuleDto CreateLinks(Resources resources, DomainId? runningRuleId) + private RuleDto CreateLinks(Resources resources, IEnrichedRuleEntity rule, bool canRun, IRuleRunnerService ruleRunnerService) { var values = new { app = resources.App, id = Id }; @@ -129,11 +130,18 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models { AddPutLink("trigger", resources.Url(x => nameof(x.TriggerRule), values)); - if (runningRuleId == null) + if (canRun && ruleRunnerService.CanRunRule(rule)) { AddPutLink("run", resources.Url(x => nameof(x.PutRuleRun), values)); } + if (canRun && ruleRunnerService.CanRunFromSnapshots(rule)) + { + var snaphshotValues = new { app = resources.App, id = Id, fromSnapshots = true }; + + AddPutLink("run/snapshots", resources.Url(x => nameof(x.PutRuleRun), snaphshotValues)); + } + AddGetLink("logs", resources.Url(x => nameof(x.GetEvents), values)); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs index 01b4786f1..2cf1cab67 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs @@ -7,7 +7,9 @@ using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.Runner; using Squidex.Infrastructure; using Squidex.Infrastructure.Validation; using Squidex.Web; @@ -27,11 +29,13 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// public DomainId? RunningRuleId { get; set; } - public static RulesDto FromRules(IEnumerable items, DomainId? runningRuleId, Resources resources) + public static async Task FromRulesAsync(IEnumerable items, IRuleRunnerService ruleRunnerService, Resources resources) { + var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(resources.Context.App.Id); + var result = new RulesDto { - Items = items.Select(x => RuleDto.FromRule(x, runningRuleId, resources)).ToArray() + Items = items.Select(x => RuleDto.FromRule(x, runningRuleId == null, ruleRunnerService, resources)).ToArray() }; result.RunningRuleId = runningRuleId; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index e7d70ac3a..be4b445fe 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -90,11 +90,9 @@ namespace Squidex.Areas.Api.Controllers.Rules { var rules = await ruleQuery.QueryAsync(Context); - var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(Context.App.Id); - - var response = Deferred.Response(() => + var response = Deferred.AsyncResponse(() => { - return RulesDto.FromRules(rules, runningRuleId, Resources); + return RulesDto.FromRulesAsync(rules, ruleRunnerService, Resources); }); return Ok(response); @@ -241,6 +239,7 @@ namespace Squidex.Areas.Api.Controllers.Rules /// /// The name of the app. /// The id of the rule to run. + /// Runs the rule from snapeshots if possible. /// /// 204 => Rule started. /// @@ -249,9 +248,9 @@ namespace Squidex.Areas.Api.Controllers.Rules [ProducesResponseType(204)] [ApiPermissionOrAnonymous(Permissions.AppRulesEvents)] [ApiCosts(1)] - public async Task PutRuleRun(string app, DomainId id) + public async Task PutRuleRun(string app, DomainId id, [FromQuery] bool fromSnapshots = false) { - await ruleRunnerService.RunAsync(App.Id, id); + await ruleRunnerService.RunAsync(App.Id, id, fromSnapshots); return NoContent(); } @@ -362,7 +361,7 @@ namespace Squidex.Areas.Api.Controllers.Rules var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(Context.App.Id); var result = context.Result(); - var response = RuleDto.FromRule(result, runningRuleId, Resources); + var response = RuleDto.FromRule(result, runningRuleId == null, ruleRunnerService, Resources); return response; } diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index 8327eb477..120d8653f 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -83,10 +83,10 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs() - .AsSelf(); - services.AddSingletonAs() + .As(); + + services.AddSingletonAs() .AsSelf(); services.AddSingletonAs>() diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs index 3fbe98183..ade05dd7e 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs @@ -93,6 +93,138 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules eventEnricher, TestUtils.DefaultSerializer, clock, log, typeNameRegistry); } + [Fact] + public void Should_not_run_from_snapshots_if_no_trigger_handler_registered() + { + var result = sut.CanCreateSnapshotEvents(RuleInvalidTrigger()); + + Assert.False(result); + } + + [Fact] + public void Should_not_run_from_snapshots_if_trigger_handler_does_not_support_it() + { + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(false); + + var result = sut.CanCreateSnapshotEvents(ValidRule()); + + Assert.False(result); + } + + [Fact] + public void Should_run_from_snapshots_if_trigger_handler_does_support_it() + { + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + var result = sut.CanCreateSnapshotEvents(ValidRule()); + + Assert.True(result); + } + + [Fact] + public async Task Should_not_create_job_from_snapshots_if_trigger_handler_does_not_support_it() + { + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(false); + + var jobs = await sut.CreateSnapshotJobsAsync(ValidRule(), ruleId, appId.Id).ToListAsync(); + + Assert.Empty(jobs); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_create_job_from_snapshots_if_rule_disabled() + { + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + var jobs = await sut.CreateSnapshotJobsAsync(ValidRule().Disable(), ruleId, appId.Id).ToListAsync(); + + Assert.Empty(jobs); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_create_job_from_snapshots_if_no_trigger_handler_registered() + { + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidTrigger(), ruleId, appId.Id).ToListAsync(); + + Assert.Empty(jobs); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_create_job_from_snapshots_if_no_action_handler_registered() + { + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidAction(), ruleId, appId.Id).ToListAsync(); + + Assert.Empty(jobs); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_create_jobs_from_snapshots() + { + var rule = ValidRule(); + + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(A._, rule.Trigger)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id)) + .Returns(new List + { + new EnrichedContentEvent { AppId = appId }, + new EnrichedContentEvent { AppId = appId } + }.ToAsyncEnumerable()); + + var result = await sut.CreateSnapshotJobsAsync(rule, ruleId, appId.Id).ToListAsync(); + + Assert.Equal(2, result.Count(x => x.Job != null && x.Exception == null)); + } + + [Fact] + public async Task Should_create_jobs_with_exceptions_from_snapshots() + { + var rule = ValidRule(); + + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(A._, rule.Trigger)) + .Throws(new InvalidOperationException()); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id)) + .Returns(new List + { + new EnrichedContentEvent { AppId = appId }, + new EnrichedContentEvent { AppId = appId } + }.ToAsyncEnumerable()); + + var result = await sut.CreateSnapshotJobsAsync(rule, ruleId, appId.Id).ToListAsync(); + + Assert.Equal(2, result.Count(x => x.Job == null && x.Exception != null)); + } + [Fact] public async Task Should_not_create_job_if_rule_disabled() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs index 7d16f27b2..8db66cf86 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs @@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Contents; @@ -27,6 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { private readonly IScriptEngine scriptEngine = A.Fake(); private readonly IAssetLoader assetLoader = A.Fake(); + private readonly IAssetRepository assetRepository = A.Fake(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly IRuleTriggerHandler sut; @@ -38,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Assets A.CallTo(() => scriptEngine.Evaluate(A._, "false", default)) .Returns(false); - sut = new AssetChangedTriggerHandler(scriptEngine, assetLoader); + sut = new AssetChangedTriggerHandler(scriptEngine, assetLoader, assetRepository); } public static IEnumerable TestEvents() @@ -49,6 +51,26 @@ namespace Squidex.Domain.Apps.Entities.Assets yield return new object[] { new AssetDeleted(), EnrichedAssetEventType.Deleted }; } + [Fact] + public async Task Should_create_events_from_snapshots() + { + var trigger = new AssetChangedTriggerV2(); + + A.CallTo(() => assetRepository.StreamAll(appId.Id)) + .Returns(new List + { + new AssetEntity(), + new AssetEntity() + }.ToAsyncEnumerable()); + + var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); + + var typed = result.OfType().ToList(); + + Assert.Equal(2, typed.Count); + Assert.Equal(2, typed.Count(x => x.Type == EnrichedAssetEventType.Created)); + } + [Theory] [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs index 423107dff..1b3a6cdef 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs @@ -43,6 +43,12 @@ namespace Squidex.Domain.Apps.Entities.Comments sut = new CommentTriggerHandler(scriptEngine, userResolver); } + [Fact] + public void Should_return_false_when_asking_for_snapshot_support() + { + Assert.False(sut.CanCreateSnapshotEvents); + } + [Fact] public async Task Should_create_enriched_events() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs index 8812b3f59..78afca71b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -17,6 +17,8 @@ using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Contents; @@ -31,6 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IScriptEngine scriptEngine = A.Fake(); private readonly ILocalCache localCache = new AsyncLocalCache(); private readonly IContentLoader contentLoader = A.Fake(); + private readonly IContentRepository contentRepository = A.Fake(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1"); private readonly NamedId schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2"); @@ -45,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => scriptEngine.Evaluate(A._, "false", default)) .Returns(false); - sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader); + sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader, contentRepository); } public static IEnumerable TestEvents() @@ -58,6 +61,61 @@ namespace Squidex.Domain.Apps.Entities.Contents yield return new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished }; } + [Fact] + public void Should_return_true_when_asking_for_snapshot_support() + { + Assert.True(sut.CanCreateSnapshotEvents); + } + + [Fact] + public async Task Should_create_events_from_snapshots() + { + var trigger = new ContentChangedTriggerV2(); + + A.CallTo(() => contentRepository.StreamAll(appId.Id, null)) + .Returns(new List + { + new ContentEntity { SchemaId = schemaMatch }, + new ContentEntity { SchemaId = schemaMatch } + }.ToAsyncEnumerable()); + + var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); + + var typed = result.OfType().ToList(); + + Assert.Equal(2, typed.Count); + Assert.Equal(2, typed.Count(x => x.Type == EnrichedContentEventType.Created)); + } + + [Fact] + public async Task Should_create_events_from_snapshots_with_schema_ids() + { + var trigger = new ContentChangedTriggerV2 + { + Schemas = new ReadOnlyCollection(new List + { + new ContentChangedTriggerSchemaV2 + { + SchemaId = schemaMatch.Id + } + }) + }; + + A.CallTo(() => contentRepository.StreamAll(appId.Id, A>.That.Is(schemaMatch.Id))) + .Returns(new List + { + new ContentEntity { SchemaId = schemaMatch }, + new ContentEntity { SchemaId = schemaMatch } + }.ToAsyncEnumerable()); + + var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); + + var typed = result.OfType().ToList(); + + Assert.Equal(2, typed.Count); + Assert.Equal(2, typed.Count(x => x.Type == EnrichedContentEventType.Created)); + } + [Theory] [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs index 30d0cfb7e..1339101ae 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs @@ -22,6 +22,12 @@ namespace Squidex.Domain.Apps.Entities.Rules { private readonly IRuleTriggerHandler sut = new ManualTriggerHandler(); + [Fact] + public void Should_return_false_when_asking_for_snapshot_support() + { + Assert.False(sut.CanCreateSnapshotEvents); + } + [Fact] public async Task Should_create_event_with_name() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs index 010e81733..8814b5cda 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs @@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Rules private readonly IClock clock = A.Fake(); private readonly ISemanticLog log = A.Dummy(); private readonly IRuleEventRepository ruleEventRepository = A.Fake(); - private readonly RuleService ruleService = A.Fake(); + private readonly IRuleService ruleService = A.Fake(); private readonly RuleDequeuerGrain sut; public RuleDequeuerGrainTests() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs index a0ef7ec70..70d127b37 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs @@ -30,9 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Rules private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly ILocalCache localCache = A.Fake(); private readonly IRuleEventRepository ruleEventRepository = A.Fake(); + private readonly IRuleService ruleService = A.Fake(); private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); - private readonly RuleService ruleService = A.Fake(); private readonly RuleEnqueuer sut; public sealed class TestAction : RuleAction @@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Rules await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); - A.CallTo(() => ruleEventRepository.EnqueueAsync(job, now, default)) + A.CallTo(() => ruleEventRepository.EnqueueAsync(job, (Exception?)null)) .MustHaveHappened(); } @@ -113,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.Rules await sut.On(@event); - A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, now, default)) + A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, (Exception?)null)) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs index 7e015f279..a0113b099 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs @@ -23,6 +23,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking private readonly DomainId ruleId = DomainId.NewGuid(); private readonly IRuleTriggerHandler sut = new UsageTriggerHandler(); + [Fact] + public void Should_return_false_when_asking_for_snapshot_support() + { + Assert.False(sut.CanCreateSnapshotEvents); + } + [Fact] public void Should_not_trigger_precheck_when_event_type_not_correct() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs index fbeb8f2c4..cd1d0d9d6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs @@ -48,6 +48,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas yield return new object[] { new SchemaUnpublished(), EnrichedSchemaEventType.Unpublished }; } + [Fact] + public void Should_return_false_when_asking_for_snapshot_support() + { + Assert.False(sut.CanCreateSnapshotEvents); + } + [Theory] [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type) diff --git a/frontend/app/features/rules/pages/rules/rule.component.html b/frontend/app/features/rules/pages/rules/rule.component.html index c8ce33832..f6cdc418f 100644 --- a/frontend/app/features/rules/pages/rules/rule.component.html +++ b/frontend/app/features/rules/pages/rules/rule.component.html @@ -24,12 +24,20 @@ confirmRememberKey="runRule"> {{ 'rules.run' | sqxTranslate }} + + + {{ 'rules.runFromSnapshots' | sqxTranslate }} + + confirmRememberKey="deleteRule"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/features/rules/pages/rules/rule.component.ts b/frontend/app/features/rules/pages/rules/rule.component.ts index b2643e341..d40c7101c 100644 --- a/frontend/app/features/rules/pages/rules/rule.component.ts +++ b/frontend/app/features/rules/pages/rules/rule.component.ts @@ -54,6 +54,10 @@ export class RuleComponent { this.rulesState.run(this.rule); } + public runFromSnapshots() { + this.rulesState.runFromSnapshots(this.rule); + } + public rename(name: string) { this.rulesState.rename(this.rule, name); } diff --git a/frontend/app/shared/services/rules.service.spec.ts b/frontend/app/shared/services/rules.service.spec.ts index 3966c6359..7dc8b78e5 100644 --- a/frontend/app/shared/services/rules.service.spec.ts +++ b/frontend/app/shared/services/rules.service.spec.ts @@ -297,6 +297,25 @@ describe('RulesService', () => { req.flush({}); })); + it('should make put request to run rule from snapshots', + inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { + + const resource: Resource = { + _links: { + ['run/snapshots']: { method: 'PUT', href: '/api/apps/my-app/rules/123/run?fromSnapshots=true' } + } + }; + + rulesService.runRuleFromSnapshots('my-app', resource).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/run?fromSnapshots=true'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + it('should make delete request to cancel run rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { diff --git a/frontend/app/shared/services/rules.service.ts b/frontend/app/shared/services/rules.service.ts index db8f46bc3..83546aa9e 100644 --- a/frontend/app/shared/services/rules.service.ts +++ b/frontend/app/shared/services/rules.service.ts @@ -130,6 +130,7 @@ export class RuleDto { public readonly canDisable: boolean; public readonly canEnable: boolean; public readonly canRun: boolean; + public readonly canRunFromSnapshots: boolean; public readonly canTrigger: boolean; public readonly canUpdate: boolean; @@ -157,6 +158,7 @@ export class RuleDto { this.canDisable = hasAnyLink(links, 'disable'); this.canEnable = hasAnyLink(links, 'enable'); this.canRun = hasAnyLink(links, 'run'); + this.canRunFromSnapshots = hasAnyLink(links, 'run/snapshots'); this.canTrigger = hasAnyLink(links, 'logs'); this.canUpdate = hasAnyLink(links, 'update'); } @@ -342,6 +344,18 @@ export class RulesService { pretifyError('i18n:rules.runFailed')); } + public runRuleFromSnapshots(appName: string, resource: Resource): Observable { + const link = resource._links['run/snapshots']; + + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url, {}).pipe( + tap(() => { + this.analytics.trackEvent('Rule', 'Run', appName); + }), + pretifyError('i18n:rules.runFailed')); + } + public runCancel(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/run`); diff --git a/frontend/app/shared/state/rules.state.spec.ts b/frontend/app/shared/state/rules.state.spec.ts index b33ee8089..4dd2ca3d7 100644 --- a/frontend/app/shared/state/rules.state.spec.ts +++ b/frontend/app/shared/state/rules.state.spec.ts @@ -175,7 +175,7 @@ describe('RulesState', () => { expect(rule1New).toEqual(rule1); }); - it('should not update rule when run', () => { + it('should not update rule when rurunningn', () => { rulesService.setup(x => x.runRule(app, rule1)) .returns(() => of()).verifiable(); @@ -186,6 +186,17 @@ describe('RulesState', () => { expect(rule1New).toEqual(rule1); }); + it('should not update rule when running from snapshots', () => { + rulesService.setup(x => x.runRuleFromSnapshots(app, rule1)) + .returns(() => of()).verifiable(); + + rulesState.runFromSnapshots(rule1).subscribe(); + + const rule1New = rulesState.snapshot.rules[0]; + + expect(rule1New).toEqual(rule1); + }); + it('should update rule when disabled', () => { const updated = createRule(1, '_new'); diff --git a/frontend/app/shared/state/rules.state.ts b/frontend/app/shared/state/rules.state.ts index 26ba829b9..6a3ef0036 100644 --- a/frontend/app/shared/state/rules.state.ts +++ b/frontend/app/shared/state/rules.state.ts @@ -176,6 +176,14 @@ export class RulesState extends State { shareSubscribed(this.dialogs)); } + public runFromSnapshots(rule: RuleDto): Observable { + return this.rulesService.runRuleFromSnapshots(this.appName, rule).pipe( + tap(() => { + this.dialogs.notifyInfo('i18n:rules.restarted'); + }), + shareSubscribed(this.dialogs)); + } + public trigger(rule: RuleDto): Observable { return this.rulesService.triggerRule(this.appName, rule).pipe( tap(() => {