Browse Source

Run rules from snapshots. (#604)

* Run rules from snapshots.

* A few small fixes.
pull/605/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
1a3f4f4231
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      backend/i18n/frontend_en.json
  2. 1
      backend/i18n/frontend_it.json
  3. 1
      backend/i18n/frontend_nl.json
  4. 1
      backend/i18n/source/frontend_en.json
  5. 13
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs
  7. 27
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs
  8. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs
  9. 145
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  10. 16
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs
  11. 16
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  12. 7
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs
  14. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  15. 31
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs
  16. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs
  17. 31
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs
  18. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  19. 36
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  20. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  21. 23
      backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs
  22. 7
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs
  23. 26
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  24. 21
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs
  25. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerGrain.cs
  26. 6
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs
  27. 154
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs
  28. 23
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  29. 7
      backend/src/Squidex.Web/Resources.cs
  30. 18
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  31. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  32. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  33. 16
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs
  34. 8
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs
  35. 13
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  36. 6
      backend/src/Squidex/Config/Domain/RuleServices.cs
  37. 132
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  38. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs
  39. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs
  40. 60
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  41. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs
  42. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs
  43. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  44. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs
  45. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs
  46. 10
      frontend/app/features/rules/pages/rules/rule.component.html
  47. 4
      frontend/app/features/rules/pages/rules/rule.component.ts
  48. 19
      frontend/app/shared/services/rules.service.spec.ts
  49. 14
      frontend/app/shared/services/rules.service.ts
  50. 13
      frontend/app/shared/state/rules.state.spec.ts
  51. 8
      frontend/app/shared/state/rules.state.ts

1
backend/i18n/frontend_en.json

@ -614,6 +614,7 @@
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Run", "rules.run": "Run",
"rules.runFailed": "Failed to run rule. Please reload.", "rules.runFailed": "Failed to run rule. Please reload.",
"rules.runFromSnapshots": "Run with latest states",
"rules.runningRule": "Rule '{name}' is currently running.", "rules.runningRule": "Rule '{name}' is currently running.",
"rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?",
"rules.runRuleConfirmTitle": "Run rule", "rules.runRuleConfirmTitle": "Run rule",

1
backend/i18n/frontend_it.json

@ -614,6 +614,7 @@
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Esegui", "rules.run": "Esegui",
"rules.runFailed": "Non è stato possibile eseguire la regola. Per favore ricarica.", "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.runningRule": "La regola '{name}' è attualmente in esecuzione.",
"rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?", "rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?",
"rules.runRuleConfirmTitle": "Esegui la regola", "rules.runRuleConfirmTitle": "Esegui la regola",

1
backend/i18n/frontend_nl.json

@ -614,6 +614,7 @@
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Uitvoeren", "rules.run": "Uitvoeren",
"rules.runFailed": "Uitvoeren van regel mislukt. Laad opnieuw.", "rules.runFailed": "Uitvoeren van regel mislukt. Laad opnieuw.",
"rules.runFromSnapshots": "Run with latest states",
"rules.runningRule": "Regel '{name}' is momenteel actief.", "rules.runningRule": "Regel '{name}' is momenteel actief.",
"rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?", "rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?",
"rules.runRuleConfirmTitle": "Regel uitvoeren", "rules.runRuleConfirmTitle": "Regel uitvoeren",

1
backend/i18n/source/frontend_en.json

@ -614,6 +614,7 @@
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Run", "rules.run": "Run",
"rules.runFailed": "Failed to run rule. Please reload.", "rules.runFailed": "Failed to run rule. Please reload.",
"rules.runFromSnapshots": "Run with latest states",
"rules.runningRule": "Rule '{name}' is currently running.", "rules.runningRule": "Rule '{name}' is currently running.",
"rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?",
"rules.runRuleConfirmTitle": "Run rule", "rules.runRuleConfirmTitle": "Run rule",

13
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs

@ -31,13 +31,18 @@ namespace Squidex.Domain.Apps.Core.HandleRules
this.userResolver = userResolver; this.userResolver = userResolver;
} }
public async Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope<AppEvent> @event) public async Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope<AppEvent>? @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 (enrichedEvent is EnrichedUserEventBase userEvent)
{ {
if (@event.Payload is SquidexEvent squidexEvent) if (@event?.Payload is SquidexEvent squidexEvent)
{ {
userEvent.Actor = squidexEvent.Actor; userEvent.Actor = squidexEvent.Actor;
} }
@ -47,8 +52,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules
userEvent.User = await FindUserAsync(userEvent.Actor); userEvent.User = await FindUserAsync(userEvent.Actor);
} }
} }
enrichedEvent.AppId = @event.Payload.AppId;
} }
private Task<IUser?> FindUserAsync(RefToken actor) private Task<IUser?> FindUserAsync(RefToken actor)

2
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs

@ -14,6 +14,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{ {
public interface IEventEnricher public interface IEventEnricher
{ {
Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope<AppEvent> @event); Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope<AppEvent>? @event);
} }
} }

27
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<List<(RuleJob Job, Exception? Exception)>> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope<IEvent> @event, bool ignoreStale = true);
Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs

@ -20,6 +20,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{ {
Type TriggerType { get; } Type TriggerType { get; }
bool CanCreateSnapshotEvents { get; }
IAsyncEnumerable<EnrichedEvent> CreateSnapshotEvents(RuleTrigger trigger, DomainId appId);
Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event); Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event);
bool Trigger(EnrichedEvent @event, RuleTrigger trigger); bool Trigger(EnrichedEvent @event, RuleTrigger trigger);

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

@ -13,6 +13,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -20,11 +21,10 @@ using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Tasks;
using Squidex.Log; 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 namespace Squidex.Domain.Apps.Core.HandleRules
{ {
public class RuleService public class RuleService : IRuleService
{ {
private readonly Dictionary<Type, IRuleActionHandler> ruleActionHandlers; private readonly Dictionary<Type, IRuleActionHandler> ruleActionHandlers;
private readonly Dictionary<Type, IRuleTriggerHandler> ruleTriggerHandlers; private readonly Dictionary<Type, IRuleTriggerHandler> ruleTriggerHandlers;
@ -68,12 +68,74 @@ namespace Squidex.Domain.Apps.Core.HandleRules
this.log = log; this.log = log;
} }
public virtual async Task<JobList> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope<IEvent> @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<List<(RuleJob Job, Exception? Exception)>> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope<IEvent> @event, bool ignoreStale = true)
{ {
Guard.NotNull(rule, nameof(rule)); Guard.NotNull(rule, nameof(rule));
Guard.NotNull(@event, nameof(@event)); Guard.NotNull(@event, nameof(@event));
var result = new JobList(); var result = new List<(RuleJob Job, Exception? Exception)>();
try try
{ {
@ -118,8 +180,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return result; return result;
} }
var expires = now.Plus(Constants.ExpirationTime);
if (!triggerHandler.Trigger(typed.Payload, rule.Trigger, ruleId)) if (!triggerHandler.Trigger(typed.Payload, rule.Trigger, ruleId))
{ {
return result; return result;
@ -140,39 +200,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules
continue; continue;
} }
var actionName = typeNameRegistry.GetName(actionType); var (job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent);
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";
result.Add((job, ex)); result.Add((job, exception));
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -192,6 +222,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return result; 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) public virtual async Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job)
{ {
var actionWatch = ValueStopwatch.StartNew(); var actionWatch = ValueStopwatch.StartNew();

16
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs

@ -28,6 +28,17 @@ namespace Squidex.Domain.Apps.Core.HandleRules
get { return typeof(TTrigger); } get { return typeof(TTrigger); }
} }
public virtual bool CanCreateSnapshotEvents
{
get { return false; }
}
public virtual async IAsyncEnumerable<EnrichedEvent> CreateSnapshotEvents(TTrigger trigger, DomainId appId)
{
await Task.Yield();
yield break;
}
public virtual async Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event) public virtual async Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event)
{ {
var enrichedEvent = await CreateEnrichedEventAsync(@event.To<TEvent>()); var enrichedEvent = await CreateEnrichedEventAsync(@event.To<TEvent>());
@ -45,6 +56,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules
} }
} }
IAsyncEnumerable<EnrichedEvent> IRuleTriggerHandler.CreateSnapshotEvents(RuleTrigger trigger, DomainId appId)
{
return CreateSnapshotEvents((TTrigger)trigger, appId);
}
bool IRuleTriggerHandler.Trigger(EnrichedEvent @event, RuleTrigger trigger) bool IRuleTriggerHandler.Trigger(EnrichedEvent @event, RuleTrigger trigger)
{ {
if (@event is TEnrichedEvent typed) if (@event is TEnrichedEvent typed)

16
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -66,6 +66,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
}, ct); }, ct);
} }
public async IAsyncEnumerable<IAssetEntity> 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<IResultList<IAssetEntity>> QueryAsync(DomainId appId, DomainId? parentId, ClrQuery query) public async Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, DomainId? parentId, ClrQuery query)
{ {
using (Profiler.TraceMethod<MongoAssetRepository>("QueryAsyncByQuery")) using (Profiler.TraceMethod<MongoAssetRepository>("QueryAsyncByQuery"))

7
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); queryContentAsync = new QueryContent(converter);
queryContentsById = new QueryContentsByIds(converter, appProvider); queryContentsById = new QueryContentsByIds(converter, appProvider);
queryContentsByQuery = new QueryContentsByQuery(converter, indexer); queryContentsByQuery = new QueryContentsByQuery(converter, indexer, appProvider);
queryIdsAsync = new QueryIdsAsync(appProvider); queryIdsAsync = new QueryIdsAsync(appProvider);
queryReferrersAsync = new QueryReferrersAsync(); queryReferrersAsync = new QueryReferrersAsync();
queryScheduledItems = new QueryScheduledContents(); queryScheduledItems = new QueryScheduledContents();
@ -65,6 +65,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
await queryScheduledItems.PrepareAsync(collection, ct); await queryScheduledItems.PrepareAsync(collection, ct);
} }
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds)
{
return queryContentsByQuery.StreamAll(appId, schemaIds);
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced) public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced)
{ {
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))

2
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); queryContentAsync = new QueryContent(converter);
queryContentsById = new QueryContentsByIds(converter, appProvider); queryContentsById = new QueryContentsByIds(converter, appProvider);
queryContentsByQuery = new QueryContentsByQuery(converter, indexer); queryContentsByQuery = new QueryContentsByQuery(converter, indexer, appProvider);
queryIdsAsync = new QueryIdsAsync(appProvider); queryIdsAsync = new QueryIdsAsync(appProvider);
} }

5
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); await collectionPublished.InitializeAsync(ct);
} }
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds)
{
return collectionAll.StreamAll(appId, schemaIds);
}
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope) public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope)
{ {
if (scope == SearchScope.All) if (scope == SearchScope.All)

31
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 DataConverter converter;
private readonly ITextIndex indexer; private readonly ITextIndex indexer;
private readonly IAppProvider appProvider;
[BsonIgnoreExtraElements] [BsonIgnoreExtraElements]
internal sealed class IdOnly internal sealed class IdOnly
@ -38,11 +39,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
public MongoContentEntity[] Joined { get; set; } public MongoContentEntity[] Joined { get; set; }
} }
public QueryContentsByQuery(DataConverter converter, ITextIndex indexer) public QueryContentsByQuery(DataConverter converter, ITextIndex indexer, IAppProvider appProvider)
{ {
this.converter = converter; this.converter = converter;
this.indexer = indexer; this.indexer = indexer;
this.appProvider = appProvider;
} }
protected override async Task PrepareAsync(CancellationToken ct = default) 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); await Collection.Indexes.CreateOneAsync(indexBySchema, cancellationToken: ct);
} }
public async IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? 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<IResultList<IContentEntity>> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope) public async Task<IResultList<IContentEntity>> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope)
{ {
Guard.NotNull(app, nameof(app)); Guard.NotNull(app, nameof(app));

4
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)); 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 }; 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; entity.DocumentId = job.Id;
await Collection.InsertOneIfNotExistsAsync(entity, ct); await Collection.InsertOneIfNotExistsAsync(entity);
} }
public Task CancelAsync(DomainId id) public Task CancelAsync(DomainId id)

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

@ -5,11 +5,13 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -21,15 +23,40 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IAssetLoader assetLoader; 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(scriptEngine, nameof(scriptEngine));
Guard.NotNull(assetLoader, nameof(assetLoader)); Guard.NotNull(assetLoader, nameof(assetLoader));
Guard.NotNull(assetRepository, nameof(assetRepository));
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.assetLoader = assetLoader; this.assetLoader = assetLoader;
this.assetRepository = assetRepository;
}
public override async IAsyncEnumerable<EnrichedEvent> 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<EnrichedAssetEvent?> CreateEnrichedEventAsync(Envelope<AssetEvent> @event) protected override async Task<EnrichedAssetEvent?> CreateEnrichedEventAsync(Envelope<AssetEvent> @event)

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
{ {
public interface IAssetRepository public interface IAssetRepository
{ {
IAsyncEnumerable<IAssetEntity> StreamAll(DomainId appId);
Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, DomainId? parentId, ClrQuery query); Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, DomainId? parentId, ClrQuery query);
Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, HashSet<DomainId> ids); Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, HashSet<DomainId> ids);

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

@ -5,12 +5,15 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -23,14 +26,45 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IContentLoader contentLoader; 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(scriptEngine, nameof(scriptEngine));
Guard.NotNull(contentLoader, nameof(contentLoader)); Guard.NotNull(contentLoader, nameof(contentLoader));
Guard.NotNull(contentRepository, nameof(contentRepository));
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.contentLoader = contentLoader; this.contentLoader = contentLoader;
this.contentRepository = contentRepository;
}
public override async IAsyncEnumerable<EnrichedEvent> 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<EnrichedContentEvent?> CreateEnrichedEventAsync(Envelope<ContentEvent> @event) protected override async Task<EnrichedContentEvent?> CreateEnrichedEventAsync(Envelope<ContentEvent> @event)

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
{ {
public interface IContentRepository public interface IContentRepository
{ {
IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds);
Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<DomainId> ids, SearchScope scope); Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<DomainId> ids, SearchScope scope);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<DomainId> ids, SearchScope scope); Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<DomainId> ids, SearchScope scope);

23
backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -17,9 +18,29 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories
{ {
public interface IRuleEventRepository 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 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); Task EnqueueAsync(DomainId id, Instant nextAttempt);

7
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs

@ -25,12 +25,15 @@ namespace Squidex.Domain.Apps.Entities.Rules
{ {
private readonly ITargetBlock<IRuleEventEntity> requestBlock; private readonly ITargetBlock<IRuleEventEntity> requestBlock;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly RuleService ruleService; private readonly IRuleService ruleService;
private readonly ConcurrentDictionary<DomainId, bool> executing = new ConcurrentDictionary<DomainId, bool>(); private readonly ConcurrentDictionary<DomainId, bool> executing = new ConcurrentDictionary<DomainId, bool>();
private readonly IClock clock; private readonly IClock clock;
private readonly ISemanticLog log; 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(ruleEventRepository, nameof(ruleEventRepository));
Guard.NotNull(ruleService, nameof(ruleService)); Guard.NotNull(ruleService, nameof(ruleService));

26
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 public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10); private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10);
private readonly IMemoryCache cache;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleService ruleService;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IMemoryCache cache;
private readonly ILocalCache localCache; private readonly ILocalCache localCache;
private readonly RuleService ruleService;
public string Name public string Name
{ {
get { return GetType().Name; } get { return GetType().Name; }
} }
public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, IRuleEventRepository ruleEventRepository, public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache,
RuleService ruleService) IRuleEventRepository ruleEventRepository,
IRuleService ruleService)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(cache, nameof(cache)); Guard.NotNull(cache, nameof(cache));
@ -59,22 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
foreach (var (job, ex) in jobs) foreach (var (job, ex) in jobs)
{ {
if (ex != null) await ruleEventRepository.EnqueueAsync(job, ex);
{
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);
}
} }
} }

21
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs

@ -7,6 +7,8 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Rules.Runner namespace Squidex.Domain.Apps.Entities.Rules.Runner
@ -14,12 +16,15 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
public sealed class GrainRuleRunnerService : IRuleRunnerService public sealed class GrainRuleRunnerService : IRuleRunnerService
{ {
private readonly IGrainFactory grainFactory; 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(grainFactory, nameof(grainFactory));
Guard.NotNull(ruleService, nameof(ruleService));
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
this.ruleService = ruleService;
} }
public Task CancelAsync(DomainId appId) public Task CancelAsync(DomainId appId)
@ -29,6 +34,16 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
return grain.CancelAsync(); 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<DomainId?> GetRunningRuleIdAsync(DomainId appId) public Task<DomainId?> GetRunningRuleIdAsync(DomainId appId)
{ {
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString()); var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
@ -36,11 +51,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
return grain.GetRunningRuleIdAsync(); return grain.GetRunningRuleIdAsync();
} }
public Task RunAsync(DomainId appId, DomainId ruleId) public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false)
{ {
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString()); var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.RunAsync(ruleId); return grain.RunAsync(ruleId, fromSnapshots);
} }
} }
} }

2
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 public interface IRuleRunnerGrain : IGrainWithStringKey
{ {
Task RunAsync(DomainId ruleId); Task RunAsync(DomainId ruleId, bool fromSnapshots);
Task CancelAsync(); Task CancelAsync();

6
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs

@ -12,10 +12,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
{ {
public interface IRuleRunnerService public interface IRuleRunnerService
{ {
Task RunAsync(DomainId appId, DomainId ruleId); Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false);
Task CancelAsync(DomainId appId); Task CancelAsync(DomainId appId);
bool CanRunRule(IRuleEntity rule);
bool CanRunFromSnapshots(IRuleEntity rule);
Task<DomainId?> GetRunningRuleIdAsync(DomainId appId); Task<DomainId?> GetRunningRuleIdAsync(DomainId appId);
} }
} }

154
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs

@ -10,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans; using Orleans;
using Orleans.Runtime; using Orleans.Runtime;
using Squidex.Caching;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -24,12 +25,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
{ {
public sealed class RuleRunnerGrain : GrainOfString, IRuleRunnerGrain, IRemindable public sealed class RuleRunnerGrain : GrainOfString, IRuleRunnerGrain, IRemindable
{ {
private const int MaxErrors = 10;
private readonly IGrainState<State> state; private readonly IGrainState<State> state;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly ILocalCache localCache;
private readonly IEventStore eventStore; private readonly IEventStore eventStore;
private readonly IEventDataFormatter eventDataFormatter; private readonly IEventDataFormatter eventDataFormatter;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly RuleService ruleService; private readonly IRuleService ruleService;
private readonly ISemanticLog log; private readonly ISemanticLog log;
private CancellationTokenSource? currentJobToken; private CancellationTokenSource? currentJobToken;
private IGrainReminder? currentReminder; private IGrainReminder? currentReminder;
@ -41,19 +44,23 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
public DomainId? RuleId { get; set; } public DomainId? RuleId { get; set; }
public string? Position { get; set; } public string? Position { get; set; }
public bool FromSnapshots { get; set; }
} }
public RuleRunnerGrain( public RuleRunnerGrain(
IGrainState<State> state, IGrainState<State> state,
IAppProvider appProvider, IAppProvider appProvider,
ILocalCache localCache,
IEventStore eventStore, IEventStore eventStore,
IEventDataFormatter eventDataFormatter, IEventDataFormatter eventDataFormatter,
IRuleEventRepository ruleEventRepository, IRuleEventRepository ruleEventRepository,
RuleService ruleService, IRuleService ruleService,
ISemanticLog log) ISemanticLog log)
{ {
Guard.NotNull(state, nameof(state)); Guard.NotNull(state, nameof(state));
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(localCache, nameof(localCache));
Guard.NotNull(eventStore, nameof(eventStore)); Guard.NotNull(eventStore, nameof(eventStore));
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter));
Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository));
@ -62,6 +69,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
this.state = state; this.state = state;
this.appProvider = appProvider; this.appProvider = appProvider;
this.localCache = localCache;
this.eventStore = eventStore; this.eventStore = eventStore;
this.eventDataFormatter = eventDataFormatter; this.eventDataFormatter = eventDataFormatter;
this.ruleEventRepository = ruleEventRepository; this.ruleEventRepository = ruleEventRepository;
@ -71,9 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
protected override Task OnActivateAsync(string key) protected override Task OnActivateAsync(string key)
{ {
EnsureIsRunning(); return EnsureIsRunningAsync(true);
return base.OnActivateAsync(key);
} }
public override Task OnDeactivateAsync() public override Task OnDeactivateAsync()
@ -104,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
return Task.FromResult(state.Value.RuleId); return Task.FromResult(state.Value.RuleId);
} }
public async Task RunAsync(DomainId ruleId) public async Task RunAsync(DomainId ruleId, bool fromSnapshots)
{ {
if (currentJobToken != null) if (currentJobToken != null)
{ {
@ -113,21 +119,33 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
state.Value = new State state.Value = new State
{ {
RuleId = ruleId RuleId = ruleId,
FromSnapshots = fromSnapshots
}; };
EnsureIsRunning(); await EnsureIsRunningAsync(false);
await state.WriteAsync(); 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(); ProcessAsync(job, ct).Forget();
} }
private async Task ProcessAsync(State job, CancellationToken ct) private async Task ProcessAsync(State currentState, CancellationToken ct)
{ {
try try
{ {
@ -144,42 +162,24 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
var rules = await appProvider.GetRulesAsync(DomainId.Create(Key)); 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) if (rule == null)
{ {
throw new InvalidOperationException("Cannot find rule."); throw new InvalidOperationException("Cannot find rule.");
} }
await eventStore.QueryAsync(async storedEvent => using (localCache.StartContext())
{ {
try if (currentState.FromSnapshots && ruleService.CanCreateSnapshotEvents(rule.RuleDef))
{
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)
{ {
log.LogWarning(ex, w => w await EnqueueFromSnapshotsAsync(rule);
.WriteProperty("action", "runRule")
.WriteProperty("status", "failedPartially3"));
} }
finally else
{ {
job.Position = storedEvent.EventPosition; await EnqueueFromEventsAsync(currentState, rule, ct);
} }
}
await state.WriteAsync();
}, $"^([a-z]+)\\-{Key}", job.Position, ct);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@ -190,14 +190,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
log.LogError(ex, w => w log.LogError(ex, w => w
.WriteProperty("action", "runRule") .WriteProperty("action", "runRule")
.WriteProperty("status", "failed") .WriteProperty("status", "failed")
.WriteProperty("ruleId", job.RuleId?.ToString())); .WriteProperty("ruleId", currentState.RuleId?.ToString()));
} }
finally finally
{ {
if (!isStopping) if (!isStopping)
{ {
job.RuleId = null; currentState.RuleId = null;
job.Position = null; currentState.Position = null;
await state.WriteAsync(); 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);
} }
} }
} }

23
backend/src/Squidex.Infrastructure/CollectionExtensions.cs

@ -8,11 +8,34 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace Squidex.Infrastructure namespace Squidex.Infrastructure
{ {
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static async Task<List<T>> ToListAsync<T>(this IAsyncEnumerable<T> source)
{
var result = new List<T>();
await foreach (var item in source)
{
result.Add(item);
}
return result;
}
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
}
public static bool SetEquals<T>(this IReadOnlyCollection<T> source, IReadOnlyCollection<T> other) public static bool SetEquals<T>(this IReadOnlyCollection<T> source, IReadOnlyCollection<T> other)
{ {
return source.Count == other.Count && source.Intersect(other).Count() == other.Count; return source.Count == other.Count && source.Intersect(other).Count() == other.Count;

7
backend/src/Squidex.Web/Resources.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Lazy; using Lazy;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using P = Squidex.Shared.Permissions; using P = Squidex.Shared.Permissions;
@ -176,13 +177,15 @@ namespace Squidex.Web
public ApiController Controller { get; } public ApiController Controller { get; }
public PermissionSet Permissions { get; } public PermissionSet Permissions => Context.Permissions;
public Context Context { get; set; }
public Resources(ApiController controller) public Resources(ApiController controller)
{ {
Controller = controller; Controller = controller;
Permissions = controller.HttpContext.Context().Permissions; Context = controller.HttpContext.Context();
} }
public string Url<T>(Func<T?, string> action, object? values = null) where T : ApiController public string Url<T>(Func<T?, string> action, object? values = null) where T : ApiController

18
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -158,7 +158,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Context, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow);
}); });
return Ok(response); return Ok(response);
@ -187,7 +187,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Context, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow);
}); });
return Ok(response); return Ok(response);
@ -218,9 +218,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
var contents = await contentQuery.QueryAsync(Context, name, CreateQuery(ids, q)); 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); 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 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); return Ok(response);
@ -280,7 +280,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var content = await contentQuery.FindAsync(Context, name, id); var content = await contentQuery.FindAsync(Context, name, id);
var response = ContentDto.FromContent(Context, content, Resources); var response = ContentDto.FromContent(content, Resources);
return Ok(response); return Ok(response);
} }
@ -307,7 +307,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var content = await contentQuery.FindAsync(Context, name, id, version); 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); return Ok(response.Data);
} }
@ -613,7 +613,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IEnrichedContentEntity>(); var result = context.Result<IEnrichedContentEntity>();
var response = ContentDto.FromContent(Context, result, Resources); var response = ContentDto.FromContent(result, Resources);
return response; return response;
} }

5
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.Areas.Api.Controllers.Schemas.Models;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
@ -104,11 +103,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary> /// </summary>
public long Version { get; set; } 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()); var response = SimpleMapper.Map(content, new ContentDto());
if (context.ShouldFlatten()) if (resources.Context.ShouldFlatten())
{ {
response.Data = content.Data.ToFlatten(); response.Data = content.Data.ToFlatten();
} }

5
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -7,7 +7,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -35,13 +34,13 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
[LocalizedRequired] [LocalizedRequired]
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents, Context context, Resources resources, public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents, Resources resources,
ISchemaEntity? schema, IContentWorkflow workflow) ISchemaEntity? schema, IContentWorkflow workflow)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
Total = contents.Total, 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) if (schema != null)

16
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.Areas.Api.Controllers.Rules.Models.Converters;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -89,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// </summary> /// </summary>
public Instant? LastExecuted { get; set; } 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(); var result = new RuleDto();
@ -101,10 +102,10 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
result.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger); 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 }; var values = new { app = resources.App, id = Id };
@ -129,11 +130,18 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
{ {
AddPutLink("trigger", resources.Url<RulesController>(x => nameof(x.TriggerRule), values)); AddPutLink("trigger", resources.Url<RulesController>(x => nameof(x.TriggerRule), values));
if (runningRuleId == null) if (canRun && ruleRunnerService.CanRunRule(rule))
{ {
AddPutLink("run", resources.Url<RulesController>(x => nameof(x.PutRuleRun), values)); AddPutLink("run", resources.Url<RulesController>(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<RulesController>(x => nameof(x.PutRuleRun), snaphshotValues));
}
AddGetLink("logs", resources.Url<RulesController>(x => nameof(x.GetEvents), values)); AddGetLink("logs", resources.Url<RulesController>(x => nameof(x.GetEvents), values));
} }

8
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs

@ -7,7 +7,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Squidex.Web; using Squidex.Web;
@ -27,11 +29,13 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// </summary> /// </summary>
public DomainId? RunningRuleId { get; set; } public DomainId? RunningRuleId { get; set; }
public static RulesDto FromRules(IEnumerable<IEnrichedRuleEntity> items, DomainId? runningRuleId, Resources resources) public static async Task<RulesDto> FromRulesAsync(IEnumerable<IEnrichedRuleEntity> items, IRuleRunnerService ruleRunnerService, Resources resources)
{ {
var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(resources.Context.App.Id);
var result = new RulesDto 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; result.RunningRuleId = runningRuleId;

13
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 rules = await ruleQuery.QueryAsync(Context);
var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(Context.App.Id); var response = Deferred.AsyncResponse(() =>
var response = Deferred.Response(() =>
{ {
return RulesDto.FromRules(rules, runningRuleId, Resources); return RulesDto.FromRulesAsync(rules, ruleRunnerService, Resources);
}); });
return Ok(response); return Ok(response);
@ -241,6 +239,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="id">The id of the rule to run.</param> /// <param name="id">The id of the rule to run.</param>
/// <param name="fromSnapshots">Runs the rule from snapeshots if possible.</param>
/// <returns> /// <returns>
/// 204 => Rule started. /// 204 => Rule started.
/// </returns> /// </returns>
@ -249,9 +248,9 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ProducesResponseType(204)] [ProducesResponseType(204)]
[ApiPermissionOrAnonymous(Permissions.AppRulesEvents)] [ApiPermissionOrAnonymous(Permissions.AppRulesEvents)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PutRuleRun(string app, DomainId id) public async Task<IActionResult> PutRuleRun(string app, DomainId id, [FromQuery] bool fromSnapshots = false)
{ {
await ruleRunnerService.RunAsync(App.Id, id); await ruleRunnerService.RunAsync(App.Id, id, fromSnapshots);
return NoContent(); return NoContent();
} }
@ -362,7 +361,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(Context.App.Id); var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(Context.App.Id);
var result = context.Result<IEnrichedRuleEntity>(); var result = context.Result<IEnrichedRuleEntity>();
var response = RuleDto.FromRule(result, runningRuleId, Resources); var response = RuleDto.FromRule(result, runningRuleId == null, ruleRunnerService, Resources);
return response; return response;
} }

6
backend/src/Squidex/Config/Domain/RuleServices.cs

@ -83,10 +83,10 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<PredefinedPatternsFormatter>() services.AddSingletonAs<PredefinedPatternsFormatter>()
.As<IRuleEventFormatter>(); .As<IRuleEventFormatter>();
services.AddSingletonAs<RuleEventFormatter>()
.AsSelf();
services.AddSingletonAs<RuleService>() services.AddSingletonAs<RuleService>()
.As<IRuleService>();
services.AddSingletonAs<RuleEventFormatter>()
.AsSelf(); .AsSelf();
services.AddSingletonAs<GrainBootstrap<IRuleDequeuerGrain>>() services.AddSingletonAs<GrainBootstrap<IRuleDequeuerGrain>>()

132
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); 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<RuleTrigger>._, A<DomainId>._))
.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<RuleTrigger>._, A<DomainId>._))
.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<RuleTrigger>._, A<DomainId>._))
.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<RuleTrigger>._, A<DomainId>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_jobs_from_snapshots()
{
var rule = ValidRule();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, rule.Trigger))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id))
.Returns(new List<EnrichedEvent>
{
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<EnrichedEvent>._, rule.Trigger))
.Throws(new InvalidOperationException());
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id))
.Returns(new List<EnrichedEvent>
{
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] [Fact]
public async Task Should_not_create_job_if_rule_disabled() public async Task Should_not_create_job_if_rule_disabled()
{ {

24
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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
@ -27,6 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>(); private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>();
private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly IRuleTriggerHandler sut; private readonly IRuleTriggerHandler sut;
@ -38,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
A.CallTo(() => scriptEngine.Evaluate(A<ScriptVars>._, "false", default)) A.CallTo(() => scriptEngine.Evaluate(A<ScriptVars>._, "false", default))
.Returns(false); .Returns(false);
sut = new AssetChangedTriggerHandler(scriptEngine, assetLoader); sut = new AssetChangedTriggerHandler(scriptEngine, assetLoader, assetRepository);
} }
public static IEnumerable<object[]> TestEvents() public static IEnumerable<object[]> TestEvents()
@ -49,6 +51,26 @@ namespace Squidex.Domain.Apps.Entities.Assets
yield return new object[] { new AssetDeleted(), EnrichedAssetEventType.Deleted }; 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<AssetEntity>
{
new AssetEntity(),
new AssetEntity()
}.ToAsyncEnumerable());
var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync();
var typed = result.OfType<EnrichedAssetEvent>().ToList();
Assert.Equal(2, typed.Count);
Assert.Equal(2, typed.Count(x => x.Type == EnrichedAssetEventType.Created));
}
[Theory] [Theory]
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type) public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type)

6
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); sut = new CommentTriggerHandler(scriptEngine, userResolver);
} }
[Fact]
public void Should_return_false_when_asking_for_snapshot_support()
{
Assert.False(sut.CanCreateSnapshotEvents);
}
[Fact] [Fact]
public async Task Should_create_enriched_events() public async Task Should_create_enriched_events()
{ {

60
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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; 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;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
@ -31,6 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly ILocalCache localCache = new AsyncLocalCache(); private readonly ILocalCache localCache = new AsyncLocalCache();
private readonly IContentLoader contentLoader = A.Fake<IContentLoader>(); private readonly IContentLoader contentLoader = A.Fake<IContentLoader>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1"); private readonly NamedId<DomainId> schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1");
private readonly NamedId<DomainId> schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2"); private readonly NamedId<DomainId> schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2");
@ -45,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => scriptEngine.Evaluate(A<ScriptVars>._, "false", default)) A.CallTo(() => scriptEngine.Evaluate(A<ScriptVars>._, "false", default))
.Returns(false); .Returns(false);
sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader); sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader, contentRepository);
} }
public static IEnumerable<object[]> TestEvents() public static IEnumerable<object[]> TestEvents()
@ -58,6 +61,61 @@ namespace Squidex.Domain.Apps.Entities.Contents
yield return new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished }; 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<ContentEntity>
{
new ContentEntity { SchemaId = schemaMatch },
new ContentEntity { SchemaId = schemaMatch }
}.ToAsyncEnumerable());
var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync();
var typed = result.OfType<EnrichedContentEvent>().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<ContentChangedTriggerSchemaV2>(new List<ContentChangedTriggerSchemaV2>
{
new ContentChangedTriggerSchemaV2
{
SchemaId = schemaMatch.Id
}
})
};
A.CallTo(() => contentRepository.StreamAll(appId.Id, A<HashSet<DomainId>>.That.Is(schemaMatch.Id)))
.Returns(new List<ContentEntity>
{
new ContentEntity { SchemaId = schemaMatch },
new ContentEntity { SchemaId = schemaMatch }
}.ToAsyncEnumerable());
var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync();
var typed = result.OfType<EnrichedContentEvent>().ToList();
Assert.Equal(2, typed.Count);
Assert.Equal(2, typed.Count(x => x.Type == EnrichedContentEventType.Created));
}
[Theory] [Theory]
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type)

6
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(); private readonly IRuleTriggerHandler sut = new ManualTriggerHandler();
[Fact]
public void Should_return_false_when_asking_for_snapshot_support()
{
Assert.False(sut.CanCreateSnapshotEvents);
}
[Fact] [Fact]
public async Task Should_create_event_with_name() public async Task Should_create_event_with_name()
{ {

2
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<IClock>(); private readonly IClock clock = A.Fake<IClock>();
private readonly ISemanticLog log = A.Dummy<ISemanticLog>(); private readonly ISemanticLog log = A.Dummy<ISemanticLog>();
private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>(); private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>();
private readonly RuleService ruleService = A.Fake<RuleService>(); private readonly IRuleService ruleService = A.Fake<IRuleService>();
private readonly RuleDequeuerGrain sut; private readonly RuleDequeuerGrain sut;
public RuleDequeuerGrainTests() public RuleDequeuerGrainTests()

6
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 IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly ILocalCache localCache = A.Fake<ILocalCache>(); private readonly ILocalCache localCache = A.Fake<ILocalCache>();
private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>(); private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>();
private readonly IRuleService ruleService = A.Fake<IRuleService>();
private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); private readonly Instant now = SystemClock.Instance.GetCurrentInstant();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly RuleService ruleService = A.Fake<RuleService>();
private readonly RuleEnqueuer sut; private readonly RuleEnqueuer sut;
public sealed class TestAction : RuleAction public sealed class TestAction : RuleAction
@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event);
A.CallTo(() => ruleEventRepository.EnqueueAsync(job, now, default)) A.CallTo(() => ruleEventRepository.EnqueueAsync(job, (Exception?)null))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -113,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
await sut.On(@event); await sut.On(@event);
A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, now, default)) A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, (Exception?)null))
.MustHaveHappened(); .MustHaveHappened();
} }

6
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 DomainId ruleId = DomainId.NewGuid();
private readonly IRuleTriggerHandler sut = new UsageTriggerHandler(); private readonly IRuleTriggerHandler sut = new UsageTriggerHandler();
[Fact]
public void Should_return_false_when_asking_for_snapshot_support()
{
Assert.False(sut.CanCreateSnapshotEvents);
}
[Fact] [Fact]
public void Should_not_trigger_precheck_when_event_type_not_correct() public void Should_not_trigger_precheck_when_event_type_not_correct()
{ {

6
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 }; yield return new object[] { new SchemaUnpublished(), EnrichedSchemaEventType.Unpublished };
} }
[Fact]
public void Should_return_false_when_asking_for_snapshot_support()
{
Assert.False(sut.CanCreateSnapshotEvents);
}
[Theory] [Theory]
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type) public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type)

10
frontend/app/features/rules/pages/rules/rule.component.html

@ -24,12 +24,20 @@
confirmRememberKey="runRule"> confirmRememberKey="runRule">
{{ 'rules.run' | sqxTranslate }} {{ 'rules.run' | sqxTranslate }}
</a> </a>
<a class="dropdown-item" *ngIf="rule.canRunFromSnapshots"
(sqxConfirmClick)="runFromSnapshots()"
confirmTitle="i18n:rules.runRuleConfirmTitle"
confirmText="i18n:rules.runRuleConfirmText"
confirmRememberKey="runRuleFromSnapshots">
{{ 'rules.runFromSnapshots' | sqxTranslate }}
</a>
<a class="dropdown-item dropdown-item-delete" *ngIf="rule.canDelete" <a class="dropdown-item dropdown-item-delete" *ngIf="rule.canDelete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:rules.deleteConfirmTitle" confirmTitle="i18n:rules.deleteConfirmTitle"
confirmText="i18n:rules.deleteConfirmText" confirmText="i18n:rules.deleteConfirmText"
confirmRememberKey="deleteContent"> confirmRememberKey="deleteRule">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>

4
frontend/app/features/rules/pages/rules/rule.component.ts

@ -54,6 +54,10 @@ export class RuleComponent {
this.rulesState.run(this.rule); this.rulesState.run(this.rule);
} }
public runFromSnapshots() {
this.rulesState.runFromSnapshots(this.rule);
}
public rename(name: string) { public rename(name: string) {
this.rulesState.rename(this.rule, name); this.rulesState.rename(this.rule, name);
} }

19
frontend/app/shared/services/rules.service.spec.ts

@ -297,6 +297,25 @@ describe('RulesService', () => {
req.flush({}); 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', it('should make delete request to cancel run rule',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {

14
frontend/app/shared/services/rules.service.ts

@ -130,6 +130,7 @@ export class RuleDto {
public readonly canDisable: boolean; public readonly canDisable: boolean;
public readonly canEnable: boolean; public readonly canEnable: boolean;
public readonly canRun: boolean; public readonly canRun: boolean;
public readonly canRunFromSnapshots: boolean;
public readonly canTrigger: boolean; public readonly canTrigger: boolean;
public readonly canUpdate: boolean; public readonly canUpdate: boolean;
@ -157,6 +158,7 @@ export class RuleDto {
this.canDisable = hasAnyLink(links, 'disable'); this.canDisable = hasAnyLink(links, 'disable');
this.canEnable = hasAnyLink(links, 'enable'); this.canEnable = hasAnyLink(links, 'enable');
this.canRun = hasAnyLink(links, 'run'); this.canRun = hasAnyLink(links, 'run');
this.canRunFromSnapshots = hasAnyLink(links, 'run/snapshots');
this.canTrigger = hasAnyLink(links, 'logs'); this.canTrigger = hasAnyLink(links, 'logs');
this.canUpdate = hasAnyLink(links, 'update'); this.canUpdate = hasAnyLink(links, 'update');
} }
@ -342,6 +344,18 @@ export class RulesService {
pretifyError('i18n:rules.runFailed')); pretifyError('i18n:rules.runFailed'));
} }
public runRuleFromSnapshots(appName: string, resource: Resource): Observable<any> {
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<any> { public runCancel(appName: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/run`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/run`);

13
frontend/app/shared/state/rules.state.spec.ts

@ -175,7 +175,7 @@ describe('RulesState', () => {
expect(rule1New).toEqual(rule1); 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)) rulesService.setup(x => x.runRule(app, rule1))
.returns(() => of()).verifiable(); .returns(() => of()).verifiable();
@ -186,6 +186,17 @@ describe('RulesState', () => {
expect(rule1New).toEqual(rule1); 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', () => { it('should update rule when disabled', () => {
const updated = createRule(1, '_new'); const updated = createRule(1, '_new');

8
frontend/app/shared/state/rules.state.ts

@ -176,6 +176,14 @@ export class RulesState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public runFromSnapshots(rule: RuleDto): Observable<any> {
return this.rulesService.runRuleFromSnapshots(this.appName, rule).pipe(
tap(() => {
this.dialogs.notifyInfo('i18n:rules.restarted');
}),
shareSubscribed(this.dialogs));
}
public trigger(rule: RuleDto): Observable<any> { public trigger(rule: RuleDto): Observable<any> {
return this.rulesService.triggerRule(this.appName, rule).pipe( return this.rulesService.triggerRule(this.appName, rule).pipe(
tap(() => { tap(() => {

Loading…
Cancel
Save