From 38ed425b952a8445a9fe582b99b86e43ed98368f Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 20 Feb 2023 19:59:10 +0100 Subject: [PATCH] Rule improvements (#975) * Improve rule performance. * Back to async enumerable. * Migration to usage tracker. * Use proper data type. * Fine tuning. * Fix tests * Fix backend tests. * More tests fixed. * Some unrelated tests --- backend/i18n/frontend_en.json | 8 +- backend/i18n/frontend_it.json | 8 +- backend/i18n/frontend_nl.json | 8 +- backend/i18n/frontend_pt.json | 8 +- backend/i18n/frontend_zh.json | 8 +- backend/i18n/source/frontend_en.json | 8 +- backend/i18n/source/frontend_it.json | 1 - backend/i18n/source/frontend_nl.json | 1 - backend/i18n/source/frontend_pt.json | 2 - backend/i18n/source/frontend_zh.json | 1 - .../ContentChangedTriggerSchema.cs | 4 +- .../EnrichedContentEventType.cs | 3 +- .../Rules/Triggers/ContentChangedTriggerV2.cs | 4 +- ...dTriggerSchemaV2.cs => SchemaCondition.cs} | 2 +- .../HandleRules/IRuleService.cs | 5 +- .../HandleRules/IRuleTriggerHandler.cs | 7 +- .../HandleRules/JobResult.cs | 23 +- .../HandleRules/RuleContext.cs | 32 ++ .../HandleRules/RuleOptions.cs | 2 + .../HandleRules/RuleService.cs | 384 ++++++++++++------ .../Scripting/Internal/JintExtensions.cs | 2 +- .../Scripting/ScriptExecutionContext.cs | 47 +-- .../Contents/MongoContentCollection.cs | 10 +- .../Contents/MongoContentRepository.cs | 12 +- .../Contents/Operations/QueryAsStream.cs | 8 - .../Contents/Operations/QueryByQuery.cs | 6 +- .../Operations/QueryInDedicatedCollection.cs | 6 +- .../Contents/Operations/QueryReferrers.cs | 37 +- .../Rules/MongoRuleEventEntity.cs | 13 +- .../Rules/MongoRuleEventRepository.cs | 42 +- .../Rules/MongoRuleStatisticsCollection.cs | 89 ---- .../Assets/AssetChangedTriggerHandler.cs | 11 +- .../Assets/AssetStats.cs | 12 - .../Assets/AssetUsageTracker.cs | 12 +- .../Assets/AssetUsageTracker_EventHandling.cs | 15 +- .../Assets/IAssetUsageTracker.cs | 24 +- .../Billing/IUsageGate.cs | 13 +- .../Billing/UsageGate.Assets.cs | 145 +++++++ .../Billing/UsageGate.Rules.cs | 140 +++++++ .../Billing/UsageGate.cs | 125 +----- .../Comments/CommentTriggerHandler.cs | 11 +- .../Contents/ContentChangedTriggerHandler.cs | 124 ++++-- .../Repositories/IContentRepository.cs | 5 +- .../Guards/RuleTriggerValidator.cs | 2 +- .../Rules/DomainObject/RuleDomainObject.cs | 2 +- .../Rules/IEnrichedRuleEntity.cs | 8 +- .../Rules/IRuleEnqueuer.cs | 4 +- .../Rules/IRuleUsageTracker.cs | 35 ++ .../Rules/ManualTriggerHandler.cs | 2 +- .../Rules/Queries/RuleEnricher.cs | 24 +- .../Repositories/IRuleEventRepository.cs | 32 +- .../Rules/Repositories/RuleStatistics.cs | 24 -- .../Rules/RuleDequeuerWorker.cs | 9 + .../Rules/RuleEnqueuer.cs | 79 ++-- .../Rules/RuleEntity.cs | 4 +- .../Rules/RuleQueueWriter.cs | 90 ++++ .../Rules/Runner/DefaultRuleRunnerService.cs | 76 ++-- .../Rules/Runner/RuleRunnerProcessor.cs | 74 ++-- .../Rules/Runner/SimulatedRuleEvent.cs | 2 + .../Rules/UsageTracking/UsageTrackerWorker.cs | 38 +- .../UsageTracking/UsageTriggerHandler.cs | 9 +- .../Schemas/SchemaChangedTriggerHandler.cs | 11 +- .../EventSourcing/GetEventStore.cs | 17 +- .../EventSourcing/MongoEventStore_Writer.cs | 6 - .../UsageTracking/MongoUsageRepository.cs | 18 +- .../CollectionExtensions.cs | 49 +++ .../EventSourcing/IEventStore.cs | 3 - .../InstantExtensions.cs | 10 + .../Tasks/AsyncHelper.cs | 14 +- .../UsageTracking/ApiStats.cs | 2 +- .../UsageTracking/ApiUsageTracker.cs | 10 +- .../UsageTracking/BackgroundUsageTracker.cs | 20 +- .../UsageTracking/CachingUsageTracker.cs | 8 +- .../UsageTracking/IApiUsageTracker.cs | 8 +- .../UsageTracking/IUsageRepository.cs | 2 +- .../UsageTracking/IUsageTracker.cs | 8 +- .../UsageTracking/StoredUsage.cs | 2 +- .../UsageTracking/UsageUpdate.cs | 4 +- .../Squidex.Web/Pipeline/ApiCostsFilter.cs | 30 +- .../Pipeline/IgnoreCacheFilterAttribute.cs | 1 + .../Squidex.Web/Pipeline/UsageMiddleware.cs | 7 +- .../Api/Config/OpenApi/OpenApiServices.cs | 1 + .../Controllers/Assets/AssetsController.cs | 2 +- .../Converters/RuleTriggerDtoFactory.cs | 15 +- .../Api/Controllers/Rules/Models/RuleDto.cs | 5 +- .../Rules/Models/SimulatedRuleEventDto.cs | 6 + .../Triggers/AssetChangedRuleTriggerDto.cs | 5 + .../Models/Triggers/CommentRuleTriggerDto.cs | 5 + .../Triggers/ContentChangedRuleTriggerDto.cs | 17 +- .../ContentChangedRuleTriggerSchemaDto.cs | 37 -- .../Models/Triggers/ManualRuleTriggerDto.cs | 5 + .../Triggers/SchemaChangedRuleTriggerDto.cs | 5 + .../Models/Triggers/UsageRuleTriggerDto.cs | 5 + .../Converters/FieldPropertiesDtoFactory.cs | 57 ++- .../Models/Fields/ArrayFieldPropertiesDto.cs | 9 +- .../Models/Fields/AssetsFieldPropertiesDto.cs | 9 +- .../Fields/BooleanFieldPropertiesDto.cs | 9 +- .../Fields/ComponentFieldPropertiesDto.cs | 9 +- .../Fields/ComponentsFieldPropertiesDto.cs | 9 +- .../Fields/DateTimeFieldPropertiesDto.cs | 9 +- .../Fields/GeolocationFieldPropertiesDto.cs | 9 +- .../Models/Fields/JsonFieldPropertiesDto.cs | 9 +- .../Models/Fields/NumberFieldPropertiesDto.cs | 9 +- .../Fields/ReferencesFieldPropertiesDto.cs | 9 +- .../Models/Fields/StringFieldPropertiesDto.cs | 9 +- .../Models/Fields/TagsFieldPropertiesDto.cs | 9 +- .../Models/Fields/UIFieldPropertiesDto.cs | 9 +- .../Statistics/Models/CallsUsagePerDateDto.cs | 14 +- .../Models/StorageUsagePerDateDto.cs | 9 +- .../Statistics/UsagesController.cs | 54 +-- .../Config/Domain/SubscriptionServices.cs | 3 +- backend/src/Squidex/Squidex.csproj | 1 - backend/src/Squidex/appsettings.json | 2 +- .../HandleRules/RuleServiceTests.cs | 242 +++++------ .../Scripting/JintScriptEngineTests.cs | 114 +++--- .../SubscriptionPublisherTests.cs | 20 +- .../Apps/AppPermanentDeleterTests.cs | 12 +- .../Assets/AssetChangedTriggerHandlerTests.cs | 16 +- .../Assets/AssetPermanentDeleterTests.cs | 12 +- .../Assets/AssetUsageTrackerTests.cs | 27 +- .../Assets/RecursiveDeleterTests.cs | 12 +- .../Billing/UsageGateTests.cs | 335 ++++++++++----- .../Comments/CommentTriggerHandlerTests.cs | 24 +- .../ContentChangedTriggerHandlerTests.cs | 281 +++++++++---- .../DomainObject/Guards/GuardRuleTests.cs | 6 +- .../Triggers/ContentChangedTriggerTests.cs | 8 +- .../DomainObject/RuleDomainObjectTests.cs | 2 +- .../Rules/ManualTriggerHandlerTests.cs | 4 +- .../Rules/Queries/RuleEnricherTests.cs | 30 +- .../Rules/RuleDequeuerWorkerTests.cs | 22 +- .../Rules/RuleEnqueuerTests.cs | 209 +++++++--- .../UsageTracking/UsageTriggerHandlerTests.cs | 15 +- .../SchemaChangedTriggerHandlerTests.cs | 27 +- .../Squidex.Domain.Apps.Entities.Tests.csproj | 4 +- .../EventSourcing/EventStoreTests.cs | 32 +- .../EventSourcing/MongoParallelInsertTests.cs | 2 +- .../UsageTracking/ApiUsageTrackerTests.cs | 10 +- .../BackgroundUsageTrackerTests.cs | 14 +- .../UsageTracking/CachingUsageTrackerTests.cs | 4 +- .../Pipeline/ApiCostsFilterTests.cs | 9 +- .../Pipeline/UsageMiddlewareTests.cs | 19 +- .../rules/pages/rules/rule.component.html | 7 +- .../rule-simulator-page.component.html | 2 +- .../rule-simulator-page.component.ts | 6 +- .../asset-changed-trigger.component.html | 4 +- .../triggers/comment-trigger.component.html | 4 +- .../content-changed-schema.component.html | 2 +- .../content-changed-trigger.component.html | 100 +++-- .../content-changed-trigger.component.scss | 6 +- .../content-changed-trigger.component.ts | 16 +- .../schema-changed-trigger.component.html | 4 +- .../app/shared/services/rules.service.spec.ts | 12 +- .../src/app/shared/services/rules.service.ts | 8 +- frontend/src/app/shared/state/rules.forms.ts | 3 + frontend/src/app/theme/_common.scss | 6 + .../TestSuite.ApiTests/GraphQLTests.cs | 2 +- .../TestSuite.ApiTests/RuleRunnerTests.cs | 102 +++++ .../Model/TestEntityWithReferences.cs | 4 +- .../TestSuite.Shared/TestSuite.Shared.csproj | 4 +- 159 files changed, 2698 insertions(+), 1643 deletions(-) rename backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/{ContentChangedTriggerSchemaV2.cs => SchemaCondition.cs} (91%) delete mode 100644 backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs delete mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/AssetStats.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Assets.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleUsageTracker.cs delete mode 100644 backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Rules/RuleQueueWriter.cs delete mode 100644 backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 342c3cd71..5e034ee92 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -681,9 +681,9 @@ "roles.updateFailed": "Failed to update role. Please reload.", "rules.actionData": "Action Data", "rules.actionHint": "The selection of the action type cannot be changed later.", - "rules.addSchema": "Add Schema", "rules.advancedFormattingHint": "You can use advanced formatting", "rules.cancelFailed": "Failed to cancel rule. Please reload.", + "rules.condition": "Condition", "rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditions.commentKeyword": "Only for text keywords", @@ -703,6 +703,8 @@ "rules.createTooltip": "New Rule", "rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmTitle": "Delete rule", + "rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?", + "rules.deleteContentChangedSchemaTitle": "Remove schema", "rules.deleteFailed": "Failed to delete rule. Please reload.", "rules.empty": "No rule created yet.", "rules.emptyAddRule": "Add Rule", @@ -712,6 +714,8 @@ "rules.listPageTitle": "Rules", "rules.loadFailed": "Failed to load Rules. Please reload.", "rules.readMore": "Read More", + "rules.referencedSchemas": "Referenced schemas", + "rules.referencedSchemasHint": "Define a list of changes. Whenever a change happened all content items that are referencing this content item are queried and one event is created for each of these content items.", "rules.refreshEventsTooltip": "Refresh Events", "rules.refreshTooltip": "Refresh Rules", "rules.reloaded": "Rules reloaded.", @@ -739,6 +743,7 @@ "rules.runningRule": "Rule '{name}' is currently running.", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmTitle": "Run rule", + "rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.", "rules.simulate": "Simulate", "rules.simulateTooltip": "Simulate this rules using the last 100 events.", "rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.", @@ -766,6 +771,7 @@ "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Unnamed Rule", "rules.updateFailed": "Failed to update rule. Please reload.", + "rules.when": "When", "schemas.addField": "Add Field", "schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndCreate": "Create and add field", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 37edffb5a..0eef06174 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -681,9 +681,9 @@ "roles.updateFailed": "Non è stato possibile aggiornare il ruolo. Per favore ricarica.", "rules.actionData": "Action Data", "rules.actionHint": "The selection of the action type cannot be changed later.", - "rules.addSchema": "Add Schema", "rules.advancedFormattingHint": "You can use advanced formatting", "rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", + "rules.condition": "Condition", "rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditions.commentKeyword": "Only for text keywords", @@ -703,6 +703,8 @@ "rules.createTooltip": "Nuova regola", "rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?", "rules.deleteConfirmTitle": "Cancella la regola", + "rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?", + "rules.deleteContentChangedSchemaTitle": "Remove schema", "rules.deleteFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", "rules.empty": "Nessuna regola è stato ancora creata.", "rules.emptyAddRule": "Aggiungi una regola", @@ -712,6 +714,8 @@ "rules.listPageTitle": "Regole", "rules.loadFailed": "Non è stato possibile caricare le regole. Per favore ricarica.", "rules.readMore": "Leggi di più", + "rules.referencedSchemas": "Referenced schemas", + "rules.referencedSchemasHint": "Define a list of changes. Whenever a change happened all content items that are referencing this content item are queried and one event is created for each of these content items.", "rules.refreshEventsTooltip": "Aggiorna gli Eventi", "rules.refreshTooltip": "Aggiorna le Regole", "rules.reloaded": "Regole ricaricate.", @@ -739,6 +743,7 @@ "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", + "rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.", "rules.simulate": "Simulate", "rules.simulateTooltip": "Simulate this rules using the last 100 events.", "rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.", @@ -766,6 +771,7 @@ "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Regola senza nome", "rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.", + "rules.when": "When", "schemas.addField": "Aggiungi un Campo", "schemas.addFieldAndClose": "Crea e chiudi", "schemas.addFieldAndCreate": "Crea e aggiungi il campo", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 112673f52..1213cc28d 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -681,9 +681,9 @@ "roles.updateFailed": "Update rol mislukt. Laad opnieuw.", "rules.actionData": "Actiegegevens", "rules.actionHint": "De selectie van het actietype kan later niet worden gewijzigd.", - "rules.addSchema": "Add Schema", "rules.advancedFormattingHint": "You can use advanced formatting", "rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.", + "rules.condition": "Condition", "rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditions.commentKeyword": "Only for text keywords", @@ -703,6 +703,8 @@ "rules.createTooltip": "Nieuwe regel", "rules.deleteConfirmText": "Wil je de regel echt verwijderen?", "rules.deleteConfirmTitle": "Regel verwijderen", + "rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?", + "rules.deleteContentChangedSchemaTitle": "Remove schema", "rules.deleteFailed": "Verwijderen van regel is mislukt. Laad opnieuw.", "rules.empty": "Nog geen regel aangemaakt.", "rules.emptyAddRule": "Regel toevoegen", @@ -712,6 +714,8 @@ "rules.listPageTitle": "Regels", "rules.loadFailed": "Laden van regels is mislukt. Laad opnieuw.", "rules.readMore": "Lees meer", + "rules.referencedSchemas": "Referenced schemas", + "rules.referencedSchemasHint": "Define a list of changes. Whenever a change happened all content items that are referencing this content item are queried and one event is created for each of these content items.", "rules.refreshEventsTooltip": "Ververs evenementen", "rules.refreshTooltip": "Vernieuwingsregels", "rules.reloaded": "Regels herladen.", @@ -739,6 +743,7 @@ "rules.runningRule": "Regel '{name}' is momenteel actief.", "rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?", "rules.runRuleConfirmTitle": "Regel uitvoeren", + "rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.", "rules.simulate": "Simuleren", "rules.simulateTooltip": "Simuleer deze regels met behulp van de laatste 100 gebeurtenissen.", "rules.simulation.actionCreated": "Taak is gemaakt op basis van de verrijkte gebeurtenis en actie en toegevoegd aan een taakwachtrij.", @@ -766,6 +771,7 @@ "rules.triggerHint": "De selectie van het triggertype kan later niet worden gewijzigd.", "rules.unnamed": "Naamloos regel", "rules.updateFailed": "Update regel mislukt. Laad opnieuw.", + "rules.when": "When", "schemas.addField": "Veld toevoegen", "schemas.addFieldAndClose": "Maken en sluiten", "schemas.addFieldAndCreate": "Maak en voeg veld toe", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 8a691c0e8..d0225b01c 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -681,9 +681,9 @@ "roles.updateFailed": "Falhou na atualização do grupo. Por favor, recarregue.", "rules.actionData": "Dados de Ação", "rules.actionHint": "A seleção do tipo de ação não pode ser alterada mais tarde.", - "rules.addSchema": "Adicionar Esquema", "rules.advancedFormattingHint": "Você pode usar formatação avançada", "rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.", + "rules.condition": "Condition", "rules.conditionHint": "Condição opcional como expressão javascript", "rules.conditionHint2": "As condições são expressões javascript que definem quando desencadear, por exemplo", "rules.conditions.commentKeyword": "Apenas para palavras-chave de texto", @@ -703,6 +703,8 @@ "rules.createTooltip": "Nova Regra", "rules.deleteConfirmText": "Quer mesmo apagar a regra?", "rules.deleteConfirmTitle": "Eliminar regra", + "rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?", + "rules.deleteContentChangedSchemaTitle": "Remove schema", "rules.deleteFailed": "Falhou em apagar a regra. Por favor, recarregue.", "rules.empty": "Nenhuma regra criada ainda.", "rules.emptyAddRule": "Adicionar Regra", @@ -712,6 +714,8 @@ "rules.listPageTitle": "Regras", "rules.loadFailed": "Falhou em carregar as regras. Por favor, recarregue.", "rules.readMore": "Ler Mais", + "rules.referencedSchemas": "Referenced schemas", + "rules.referencedSchemasHint": "Define a list of changes. Whenever a change happened all content items that are referencing this content item are queried and one event is created for each of these content items.", "rules.refreshEventsTooltip": "Atualizar eventos", "rules.refreshTooltip": "Atualizar regras", "rules.reloaded": "Regras recarregadas.", @@ -739,6 +743,7 @@ "rules.runningRule": "A regra '{name}' está atualmente em execução.", "rules.runRuleConfirmText": "Quer mesmo gerir a regra para todos os eventos?", "rules.runRuleConfirmTitle": "Regra de execução", + "rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.", "rules.simulate": "Simular", "rules.simulateTooltip": "Simular estas regras usando os últimos 100 eventos.", "rules.simulation.actionCreated": "O trabalho é criado a partir do evento e ação enriquecidos e adicionado a uma fila de trabalho.", @@ -766,6 +771,7 @@ "rules.triggerHint": "A seleção do tipo de gatilho não pode ser alterada mais tarde.", "rules.unnamed": "Regra sem nome", "rules.updateFailed": "Falhou na atualização da regra. Por favor, recarregue.", + "rules.when": "When", "schemas.addField": "Adicionar Campo", "schemas.addFieldAndClose": "Criar e fechar", "schemas.addFieldAndCreate": "Criar e adicionar campo", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 3a78f6b1e..4008b7695 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -681,9 +681,9 @@ "roles.updateFailed": "更新角色失败。请重新加载。", "rules.actionData": "动作数据", "rules.actionHint": "动作类型的选择以后不能更改。", - "rules.addSchema": "Add Schema", "rules.advancedFormattingHint": "You can use advanced formatting", "rules.cancelFailed": "取消规则失败,请重新加载。", + "rules.condition": "Condition", "rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditions.commentKeyword": "Only for text keywords", @@ -703,6 +703,8 @@ "rules.createTooltip": "新规则", "rules.deleteConfirmText": "你真的要删除规则吗?", "rules.deleteConfirmTitle": "删除规则", + "rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?", + "rules.deleteContentChangedSchemaTitle": "Remove schema", "rules.deleteFailed": "删除规则失败,请重新加载。", "rules.empty": "尚未创建规则。", "rules.emptyAddRule": "添加规则", @@ -712,6 +714,8 @@ "rules.listPageTitle": "规则", "rules.loadFailed": "加载规则失败。请重新加载。", "rules.readMore": "阅读更多", + "rules.referencedSchemas": "Referenced schemas", + "rules.referencedSchemasHint": "Define a list of changes. Whenever a change happened all content items that are referencing this content item are queried and one event is created for each of these content items.", "rules.refreshEventsTooltip": "刷新事件", "rules.refreshTooltip": "刷新规则", "rules.reloaded": "规则重新加载。", @@ -739,6 +743,7 @@ "rules.runningRule": "规则 '{name}' 当前正在运行。", "rules.runRuleConfirmText": "你真的想为所有事件运行规则吗?", "rules.runRuleConfirmTitle": "运行规则", + "rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.", "rules.simulate": "模拟", "rules.simulateTooltip": "使用最近 100 个事件模拟此规则。", "rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.", @@ -766,6 +771,7 @@ "rules.triggerHint": "以后不能更改触发器类型的选择。", "rules.unnamed": "未命名规则", "rules.updateFailed": "更新规则失败。请重新加载。", + "rules.when": "When", "schemas.addField": "添加字段", "schemas.addFieldAndClose": "创建并关闭", "schemas.addFieldAndCreate": "创建并添加字段", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 342c3cd71..5e034ee92 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -681,9 +681,9 @@ "roles.updateFailed": "Failed to update role. Please reload.", "rules.actionData": "Action Data", "rules.actionHint": "The selection of the action type cannot be changed later.", - "rules.addSchema": "Add Schema", "rules.advancedFormattingHint": "You can use advanced formatting", "rules.cancelFailed": "Failed to cancel rule. Please reload.", + "rules.condition": "Condition", "rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditions.commentKeyword": "Only for text keywords", @@ -703,6 +703,8 @@ "rules.createTooltip": "New Rule", "rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmTitle": "Delete rule", + "rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?", + "rules.deleteContentChangedSchemaTitle": "Remove schema", "rules.deleteFailed": "Failed to delete rule. Please reload.", "rules.empty": "No rule created yet.", "rules.emptyAddRule": "Add Rule", @@ -712,6 +714,8 @@ "rules.listPageTitle": "Rules", "rules.loadFailed": "Failed to load Rules. Please reload.", "rules.readMore": "Read More", + "rules.referencedSchemas": "Referenced schemas", + "rules.referencedSchemasHint": "Define a list of changes. Whenever a change happened all content items that are referencing this content item are queried and one event is created for each of these content items.", "rules.refreshEventsTooltip": "Refresh Events", "rules.refreshTooltip": "Refresh Rules", "rules.reloaded": "Rules reloaded.", @@ -739,6 +743,7 @@ "rules.runningRule": "Rule '{name}' is currently running.", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmTitle": "Run rule", + "rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.", "rules.simulate": "Simulate", "rules.simulateTooltip": "Simulate this rules using the last 100 events.", "rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.", @@ -766,6 +771,7 @@ "rules.triggerHint": "The selection of the trigger type cannot be changed later.", "rules.unnamed": "Unnamed Rule", "rules.updateFailed": "Failed to update rule. Please reload.", + "rules.when": "When", "schemas.addField": "Add Field", "schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndCreate": "Create and add field", diff --git a/backend/i18n/source/frontend_it.json b/backend/i18n/source/frontend_it.json index 0547aeda0..dd30806ff 100644 --- a/backend/i18n/source/frontend_it.json +++ b/backend/i18n/source/frontend_it.json @@ -541,7 +541,6 @@ "roles.loadPermissionsFailed": "Non è stato possibile caricare i permessi. Per favore ricarica.", "roles.permissions": "Permessi", "roles.permissionsDescription": "I permessi limitano le operazioni consentite e le interrogazioni (query) a livello di API e sono una funzionalità per garantire la sicurezza.", - "roles.permissionsPlaceholder": "Inizia a digitare per ricercare i permessi", "roles.properties": "Proprietà", "roles.properties.hideAPI": "Nascondi le API", "roles.properties.hideAssets": "Nascondi le Risorse", diff --git a/backend/i18n/source/frontend_nl.json b/backend/i18n/source/frontend_nl.json index 817adb751..634b61e40 100644 --- a/backend/i18n/source/frontend_nl.json +++ b/backend/i18n/source/frontend_nl.json @@ -620,7 +620,6 @@ "roles.loadPermissionsFailed": "Kan machtigingen niet laden. Laad opnieuw.", "roles.permissions": "Rechten", "roles.permissionsDescription": "Machtigingen beperken de toegestane bewerkingen en zoekopdrachten op API-niveau en zijn een beveiligingsfunctie.", - "roles.permissionsPlaceholder": "Begin met typen om naar rechten te zoeken", "roles.properties": "Eigenschappen", "roles.properties.hideAPI": "API verbergen", "roles.properties.hideAssets": "Assets verbergen", diff --git a/backend/i18n/source/frontend_pt.json b/backend/i18n/source/frontend_pt.json index 954b0aee6..3af508618 100644 --- a/backend/i18n/source/frontend_pt.json +++ b/backend/i18n/source/frontend_pt.json @@ -654,7 +654,6 @@ "roles.loadPermissionsFailed": "Falhou em carregar permissões. Por favor, recarregue.", "roles.permissions": "Permissões", "roles.permissionsDescription": "As permissões restringem as operações e consultas permitidas a nível API e são uma funcionalidade de segurança.", - "roles.permissionsPlaceholder": "Comece a escrever para procurar permissões", "roles.properties": "Propriedades", "roles.properties.hideAPI": "Ocultar API", "roles.properties.hideAssets": "Ocultar ficheiros", @@ -669,7 +668,6 @@ "roles.updateFailed": "Falhou na atualização do grupo. Por favor, recarregue.", "rules.actionData": "Dados de Ação", "rules.actionHint": "A seleção do tipo de ação não pode ser alterada mais tarde.", - "rules.addSchema": "Adicionar Esquema", "rules.advancedFormattingHint": "Você pode usar formatação avançada", "rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.", "rules.conditionHint": "Condição opcional como expressão javascript", diff --git a/backend/i18n/source/frontend_zh.json b/backend/i18n/source/frontend_zh.json index ec4b89da1..ea3a1b62c 100644 --- a/backend/i18n/source/frontend_zh.json +++ b/backend/i18n/source/frontend_zh.json @@ -563,7 +563,6 @@ "roles.loadPermissionsFailed": "加载权限失败。请重新加载。", "roles.permissions": "权限", "roles.permissionsDescription": "权限在 API 级别限制允许的操作和查询,是一项安全功能。", - "roles.permissionsPlaceholder": "开始输入以搜索权限", "roles.properties": "属性", "roles.properties.hideAPI": "隐藏 API", "roles.properties.hideAssets": "隐藏资源", diff --git a/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs b/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs index bb1478838..73ca29cd7 100644 --- a/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs +++ b/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs @@ -29,7 +29,7 @@ public sealed class ContentChangedTriggerSchema public bool SendRestore { get; set; } - public ContentChangedTriggerSchemaV2 Migrate() + public SchemaCondition Migrate() { var conditions = new List(); @@ -71,6 +71,6 @@ public sealed class ContentChangedTriggerSchema var schemaId = DomainId.Create(SchemaId); - return new ContentChangedTriggerSchemaV2 { SchemaId = schemaId, Condition = condition }; + return new SchemaCondition { SchemaId = schemaId, Condition = condition }; } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedContentEventType.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedContentEventType.cs index 988fd8594..68c20a3e4 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedContentEventType.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedContentEventType.cs @@ -14,5 +14,6 @@ public enum EnrichedContentEventType Published, StatusChanged, Updated, - Unpublished + Unpublished, + ReferenceUpdated } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs index e46d31db6..bdd285d82 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs @@ -13,7 +13,9 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers; [TypeName(nameof(ContentChangedTriggerV2))] public sealed record ContentChangedTriggerV2 : RuleTrigger { - public ReadonlyList? Schemas { get; init; } + public ReadonlyList? Schemas { get; init; } + + public ReadonlyList? ReferencedSchemas { get; init; } public bool HandleAll { get; init; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchemaV2.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaCondition.cs similarity index 91% rename from backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchemaV2.cs rename to backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaCondition.cs index 55e3452d4..85c586b09 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchemaV2.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaCondition.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Rules.Triggers; -public sealed record ContentChangedTriggerSchemaV2 +public record class SchemaCondition { public DomainId SchemaId { get; init; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs index b9bebb757..770b6086e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure.EventSourcing; @@ -12,14 +13,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules; public interface IRuleService { - bool CanCreateSnapshotEvents(RuleContext context); + bool CanCreateSnapshotEvents(Rule rule); string GetName(AppEvent @event); IAsyncEnumerable CreateSnapshotJobsAsync(RuleContext context, CancellationToken ct = default); - IAsyncEnumerable CreateJobsAsync(Envelope @event, RuleContext context, + IAsyncEnumerable CreateJobsAsync(Envelope @event, RulesContext context, CancellationToken ct = default); Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job, 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 8197ff814..f36de36e8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure.EventSourcing; @@ -26,7 +27,7 @@ public interface IRuleTriggerHandler return AsyncEnumerable.Empty(); } - IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, CancellationToken ct); string? GetName(AppEvent @event) @@ -34,12 +35,12 @@ public interface IRuleTriggerHandler return null; } - bool Trigger(Envelope @event, RuleContext context) + bool Trigger(Envelope @event, RuleTrigger trigger) { return true; } - bool Trigger(EnrichedEvent @event, RuleContext context) + bool Trigger(EnrichedEvent @event, RuleTrigger trigger) { return true; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs index 9ba556c1f..919a65dd0 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs @@ -7,16 +7,12 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.HandleRules; public sealed record JobResult { - public static readonly JobResult ConditionDoesNotMatch = new JobResult - { - SkipReason = SkipReason.ConditionDoesNotMatch - }; - public static readonly JobResult ConditionPrecheckDoesNotMatch = new JobResult { SkipReason = SkipReason.ConditionPrecheckDoesNotMatch @@ -57,6 +53,10 @@ public sealed record JobResult SkipReason = SkipReason.WrongEventForTrigger }; + public DomainId RuleId { get; set; } + + public Rule Rule { get; init; } + public RuleJob? Job { get; init; } public EnrichedEvent? EnrichedEvent { get; init; } @@ -65,6 +65,19 @@ public sealed record JobResult public SkipReason SkipReason { get; init; } + public int Offset { get; set; } + + public static JobResult ConditionDoesNotMatch(EnrichedEvent? enrichedEvent = null, RuleJob? job = null) + { + return new JobResult + { + Job = job, + EnrichedEvent = enrichedEvent, + EnrichmentError = null, + SkipReason = SkipReason.ConditionDoesNotMatch, + }; + } + public static JobResult Failed(Exception exception, EnrichedEvent? enrichedEvent = null, RuleJob? job = null) { return new JobResult diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs index d73ae39c8..c26298023 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs @@ -7,9 +7,27 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; + +#pragma warning disable MA0048 // File name must match type name namespace Squidex.Domain.Apps.Core.HandleRules; +public readonly struct RulesContext +{ + public NamedId AppId { get; init; } + + public ReadonlyDictionary Rules { get; init; } + + public bool IncludeSkipped { get; init; } + + public bool IncludeStale { get; init; } + + public bool AllowExtraEvents { get; init; } + + public int? MaxEvents { get; init; } +} + public readonly struct RuleContext { public NamedId AppId { get; init; } @@ -21,4 +39,18 @@ public readonly struct RuleContext public bool IncludeSkipped { get; init; } public bool IncludeStale { get; init; } + + public RulesContext ToRulesContext() + { + return new RulesContext + { + AppId = AppId, + IncludeSkipped = IncludeSkipped, + IncludeStale = IncludeStale, + Rules = new Dictionary + { + [RuleId] = Rule + }.ToReadonlyDictionary() + }; + } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs index f7716b151..31294ee63 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs @@ -11,5 +11,7 @@ public sealed class RuleOptions { public int ExecutionTimeoutInSeconds { get; set; } = 3; + public int MaxEnrichedEvents { get; set; } = 500; + public TimeSpan RulesCacheDuration { get; set; } = TimeSpan.FromSeconds(10); } 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 fbfecd219..8753238c0 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -18,6 +18,8 @@ using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Tasks; +#pragma warning disable SA1401 // Fields should be private + namespace Squidex.Domain.Apps.Core.HandleRules; public sealed class RuleService : IRuleService @@ -30,6 +32,17 @@ public sealed class RuleService : IRuleService private readonly IJsonSerializer serializer; private readonly ILogger log; + private sealed class RuleState + { + public SkipReason Skip { get; set; } + + public Rule Rule { get; init; } + + public DomainId RuleId; + + public IRuleActionHandler ActionHandler; + } + public IClock Clock { get; set; } = SystemClock.Instance; public RuleService( @@ -50,11 +63,9 @@ public sealed class RuleService : IRuleService this.log = log; } - public bool CanCreateSnapshotEvents(RuleContext context) + public bool CanCreateSnapshotEvents(Rule rule) { - Guard.NotNull(context.Rule, nameof(context.Rule)); - - if (!ruleTriggerHandlers.TryGetValue(context.Rule.Trigger.GetType(), out var triggerHandler)) + if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) { return false; } @@ -65,8 +76,6 @@ public sealed class RuleService : IRuleService public async IAsyncEnumerable CreateSnapshotJobsAsync(RuleContext context, [EnumeratorCancellation] CancellationToken ct = default) { - Guard.NotNull(context.Rule, nameof(context.Rule)); - var rule = context.Rule; if (!rule.IsEnabled && !context.IncludeSkipped) @@ -91,6 +100,9 @@ public sealed class RuleService : IRuleService var now = Clock.GetCurrentInstant(); + // Maintain an offset per event to generate a unique ID. + var offset = 0; + await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct)) { JobResult? job; @@ -98,12 +110,13 @@ public sealed class RuleService : IRuleService { await eventEnricher.EnrichAsync(enrichedEvent, null); - if (!triggerHandler.Trigger(enrichedEvent, context)) + if (!triggerHandler.Trigger(enrichedEvent, context.Rule.Trigger)) { continue; } - job = await CreateJobAsync(actionHandler, enrichedEvent, context, now); + job = await CreateJobAsync(enrichedEvent, actionHandler, context.RuleId, context.Rule, now); + job.Offset = offset++; } catch (Exception ex) { @@ -114,68 +127,135 @@ public sealed class RuleService : IRuleService } } - public async IAsyncEnumerable CreateJobsAsync(Envelope @event, RuleContext context, - [EnumeratorCancellation] CancellationToken ct = default) + public IAsyncEnumerable CreateJobsAsync(Envelope @event, RulesContext context, + CancellationToken ct = default) { - Guard.NotNull(@event, nameof(@event)); + Guard.NotNull(@event); + + // Each rule can has its own errors. + var states = context.Rules.Select(x => new RuleState + { + Rule = x.Value, + RuleId = x.Key + }).ToList(); - var jobs = new List(); + var allResults = + CreateJobs(@event, context, states, ct) + .Catch(ex => + { + log.LogError(ex, "Failed to create rule job."); + + return states.Select(state => + new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.Failed, + }); + }); - await AddJobsAsync(jobs, @event, context, ct); + return allResults; + } - foreach (var job in jobs) + private async IAsyncEnumerable CreateJobs(Envelope @event, RulesContext context, List states, + [EnumeratorCancellation] CancellationToken ct) + { + if (@event.Payload is not AppEvent) { - if (ct.IsCancellationRequested) + foreach (var state in states) { - break; + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.WrongEvent + }; } - yield return job; + yield break; } - } - private async Task AddJobsAsync(List jobs, Envelope @event, RuleContext context, - CancellationToken ct) - { - try - { - var skipReason = SkipReason.None; - - var rule = context.Rule; + var typed = @event.To(); - if (!rule.IsEnabled) + if (typed.Payload.FromRule) + { + if (context.IncludeSkipped) { - // For the simulation we want to proceed as much as possible. - if (context.IncludeSkipped) + foreach (var state in states) { - skipReason |= SkipReason.Disabled; + state.Skip |= SkipReason.FromRule; } - else + } + else + { + foreach (var state in states) { - jobs.Add(JobResult.Disabled); - return; + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.FromRule + }; } + + yield break; } + } - if (@event.Payload is not AppEvent) + var now = Clock.GetCurrentInstant(); + + var eventTime = + @event.Headers.ContainsKey(CommonHeaders.Timestamp) ? + @event.Headers.Timestamp() : + now; + + if (!context.IncludeStale && eventTime.Plus(Constants.StaleTime) < now) + { + if (context.IncludeSkipped) { - jobs.Add(JobResult.WrongEvent); - return; + foreach (var state in states) + { + state.Skip |= SkipReason.TooOld; + } } + else + { + foreach (var state in states) + { + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.TooOld + }; + } + + yield break; + } + } + + Dictionary>? matchingRules = null; - var typed = @event.To(); + foreach (var state in states) + { + var rule = state.Rule; - if (typed.Payload.FromRule) + if (!rule.IsEnabled) { - // For the simulation we want to proceed as much as possible. if (context.IncludeSkipped) { - skipReason |= SkipReason.FromRule; + state.Skip = SkipReason.Disabled; } else { - jobs.Add(JobResult.FromRule); - return; + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.Disabled + }; + + continue; } } @@ -183,119 +263,177 @@ public sealed class RuleService : IRuleService if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) { - jobs.Add(JobResult.NoTrigger); - return; + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.NoTrigger + }; + + continue; } if (!triggerHandler.Handles(typed.Payload)) { - jobs.Add(JobResult.WrongEventForTrigger); - return; - } + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.WrongEventForTrigger + }; - if (!ruleActionHandlers.TryGetValue(actionType, out var actionHandler)) - { - jobs.Add(JobResult.NoAction); - return; + continue; } - var now = Clock.GetCurrentInstant(); - - var eventTime = - @event.Headers.ContainsKey(CommonHeaders.Timestamp) ? - @event.Headers.Timestamp() : - now; - - if (!context.IncludeStale && eventTime.Plus(Constants.StaleTime) < now) + if (!ruleActionHandlers.TryGetValue(actionType, out state.ActionHandler!)) { - // For the simulation we want to proceed as much as possible. - if (context.IncludeSkipped) + yield return new JobResult { - skipReason |= SkipReason.TooOld; - } - else - { - jobs.Add(JobResult.TooOld); - return; - } + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.NoAction + }; + + continue; } - if (!triggerHandler.Trigger(typed, context)) + if (!triggerHandler.Trigger(typed, rule.Trigger)) { - // For the simulation we want to proceed as much as possible. if (context.IncludeSkipped) { - skipReason |= SkipReason.ConditionPrecheckDoesNotMatch; + state.Skip = SkipReason.ConditionPrecheckDoesNotMatch; } else { - jobs.Add(JobResult.ConditionPrecheckDoesNotMatch); - return; + yield return new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.ConditionPrecheckDoesNotMatch + }; + + continue; } } - await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(typed, context, ct)) - { - if (string.IsNullOrWhiteSpace(enrichedEvent.Name)) - { - enrichedEvent.Name = GetName(typed.Payload); - } + matchingRules ??= new (); + matchingRules.GetOrAddNew(triggerHandler).Add(state); + } - try - { - await eventEnricher.EnrichAsync(enrichedEvent, typed); + if (matchingRules == null) + { + yield break; + } - if (!triggerHandler.Trigger(enrichedEvent, context)) + foreach (var (triggerHandler, rulesByTrigger) in matchingRules) + { + var triggerResults = + CreateTriggerJobs(typed, triggerHandler, rulesByTrigger, now, context, ct) + .Catch(ex => { - // For the simulation we want to proceed as much as possible. - if (context.IncludeSkipped) - { - skipReason |= SkipReason.ConditionDoesNotMatch; - } - else - { - jobs.Add(JobResult.ConditionDoesNotMatch); - return; - } - } + log.LogError(ex, "Failed to create rule jobs from trigger."); + + return states.Select(state => + new JobResult + { + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.Failed, + }); + }); + + await foreach (var result in triggerResults.WithCancellation(ct)) + { + yield return result; + } + } + } - var job = await CreateJobAsync(actionHandler, enrichedEvent, context, now); + private async IAsyncEnumerable CreateTriggerJobs(Envelope @event, IRuleTriggerHandler triggerHandler, List states, Instant now, RulesContext context, + [EnumeratorCancellation] CancellationToken ct) + { + var takeEvents = context.MaxEvents ?? int.MaxValue; - // If the conditions matchs, we can skip creating a new object and save a few allocation.s - if (skipReason != SkipReason.None) + await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(@event, context, ct).Take(takeEvents).WithCancellation(ct)) + { + // Maintain an offset per event to generate a unique ID. + var offset = 0; + + var eventResults = + CreateEventJobs(@event, enrichedEvent, triggerHandler, states, now, context) + .Catch(ex => { - job = job with { SkipReason = skipReason }; - } + log.LogError(ex, "Failed to create rule jobs from event."); + + return states.Select(state => + new JobResult + { + EnrichedEvent = enrichedEvent, + EnrichmentError = ex, + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.Failed, + }); + }); + + await foreach (var result in eventResults.WithCancellation(ct)) + { + result.Offset = offset++; + + yield return result; + } + } + } - jobs.Add(job); + private async IAsyncEnumerable CreateEventJobs(Envelope @event, EnrichedEvent enrichedEvent, IRuleTriggerHandler triggerHandler, List states, Instant now, RulesContext context) + { + if (string.IsNullOrWhiteSpace(enrichedEvent.Name)) + { + enrichedEvent.Name = GetName(@event.Payload); + } + + await eventEnricher.EnrichAsync(enrichedEvent, @event); + + foreach (var state in states) + { + // The actual skip reason could be different per event. + var skipped = state.Skip; + + if (!triggerHandler.Trigger(enrichedEvent, state.Rule.Trigger)) + { + if (context.IncludeSkipped) + { + skipped |= SkipReason.ConditionDoesNotMatch; } - catch (Exception ex) + else { - if (jobs.Count == 0) + yield return new JobResult { - jobs.Add(new JobResult - { - EnrichedEvent = enrichedEvent, - EnrichmentError = ex, - SkipReason = SkipReason.Failed - }); - } + EnrichedEvent = enrichedEvent, + Rule = state.Rule, + RuleId = state.RuleId, + SkipReason = SkipReason.ConditionDoesNotMatch + }; - log.LogError(ex, "Failed to create rule jobs from event."); + continue; } } - } - catch (Exception ex) - { - jobs.Add(JobResult.Failed(ex)); - log.LogError(ex, "Failed to create rule job."); + var result = await CreateJobAsync(enrichedEvent, state.ActionHandler, state.RuleId, state.Rule, now); + + // If the conditions matchs, we can skip creating a new object and save a few allocations. + if (skipped != SkipReason.None) + { + result = result with { SkipReason = skipped }; + } + + yield return result; } } - private async Task CreateJobAsync(IRuleActionHandler actionHandler, EnrichedEvent enrichedEvent, RuleContext context, Instant now) + private async Task CreateJobAsync(EnrichedEvent enrichedEvent, IRuleActionHandler actionHandler, DomainId ruleId, Rule rule, Instant now) { - var actionType = context.Rule.Action.GetType(); + var actionType = rule.Action.GetType(); var actionName = typeRegistry.GetName(actionType); var expires = now.Plus(Constants.ExpirationTime); @@ -310,12 +448,12 @@ public sealed class RuleService : IRuleService EventName = enrichedEvent.Name, ExecutionPartition = enrichedEvent.Partition, Expires = expires, - RuleId = context.RuleId + RuleId = ruleId }; try { - var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action); + var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); var json = serializer.Serialize(data); @@ -323,7 +461,13 @@ public sealed class RuleService : IRuleService job.ActionName = actionName; job.Description = description; - return new JobResult { Job = job, EnrichedEvent = enrichedEvent }; + return new JobResult + { + EnrichedEvent = enrichedEvent, + Rule = rule, + RuleId = ruleId, + Job = job, + }; } catch (Exception ex) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs index 204713e41..f953ba6f4 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs @@ -58,7 +58,7 @@ public static class JintExtensions } internal static ScriptExecutionContext Extend(this ScriptExecutionContext context, - ScriptVars vars, + ScriptVars vars, ScriptOptions options) { var engine = context.Engine; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs index 7e5d640d3..1fac1b854 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Diagnostics; using Jint; using Squidex.Infrastructure.Tasks; @@ -27,7 +26,6 @@ public sealed class ScriptExecutionContext : ScriptExecutionContext, ISchedul { private readonly TaskCompletionSource tcs = new TaskCompletionSource(); private readonly CancellationToken cancellationToken; - private readonly ReaderWriterLockSlim slimLock = new ReaderWriterLockSlim(); private int pendingTasks = 1; public bool IsCompleted @@ -62,10 +60,9 @@ public sealed class ScriptExecutionContext : ScriptExecutionContext, ISchedul async Task ScheduleAsync() { + TryStart(); try { - TryStart(); - await action(this, cancellationToken); TryComplete(default!); @@ -86,21 +83,20 @@ public sealed class ScriptExecutionContext : ScriptExecutionContext, ISchedul return; } - lock (Engine) + TryStart(); + try { - try + lock (Engine) { - TryStart(); - Engine.ResetConstraints(); action(); - - TryComplete(default!); - } - catch (Exception ex) - { - TryFail(ex); } + + TryComplete(default!); + } + catch (Exception ex) + { + TryFail(ex); } } @@ -111,21 +107,20 @@ public sealed class ScriptExecutionContext : ScriptExecutionContext, ISchedul return; } - lock (Engine) + TryStart(); + try { - try + lock (Engine) { - TryStart(); - Engine.ResetConstraints(); action(argument); - - TryComplete(default!); - } - catch (Exception ex) - { - TryFail(ex); } + + TryComplete(default!); + } + catch (Exception ex) + { + TryFail(ex); } } @@ -137,8 +132,6 @@ public sealed class ScriptExecutionContext : ScriptExecutionContext, ISchedul private void TryStart() { Interlocked.Increment(ref pendingTasks); - - Debug.WriteLine(pendingTasks); } private void TryComplete(T result) @@ -147,8 +140,6 @@ public sealed class ScriptExecutionContext : ScriptExecutionContext, ISchedul { tcs.TrySetResult(result); } - - Debug.WriteLine(pendingTasks); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 6904cb226..f1307b2c6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -109,6 +109,12 @@ public sealed class MongoContentCollection : MongoRepositoryBase StreamReferencing(DomainId appId, DomainId reference, int take, + CancellationToken ct) + { + return queryReferrers.StreamReferencing(appId, reference, take, ct); + } + public IAsyncEnumerable QueryScheduledWithoutDataAsync(Instant now, CancellationToken ct) { @@ -231,12 +237,12 @@ public sealed class MongoContentCollection : MongoRepositoryBase HasReferrersAsync(DomainId appId, DomainId contentId, + public async Task HasReferrersAsync(DomainId appId, DomainId reference, CancellationToken ct) { using (Telemetry.Activities.StartActivity("MongoContentCollection/HasReferrersAsync")) { - return await queryReferrers.CheckExistsAsync(appId, contentId, ct); + return await queryReferrers.CheckExistsAsync(appId, reference, ct); } } 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 d5bfe63c8..9071f2b03 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -75,6 +75,12 @@ public partial class MongoContentRepository : MongoBase, ICo return collectionComplete.StreamAll(appId, schemaIds, ct); } + public IAsyncEnumerable StreamReferencing(DomainId appId, DomainId reference, int take, + CancellationToken ct = default) + { + return collectionComplete.StreamReferencing(appId, reference, take, ct); + } + public IAsyncEnumerable QueryScheduledWithoutDataAsync(Instant now, CancellationToken ct = default) { @@ -133,16 +139,16 @@ public partial class MongoContentRepository : MongoBase, ICo } } - public Task HasReferrersAsync(DomainId appId, DomainId contentId, SearchScope scope, + public Task HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope, CancellationToken ct = default) { if (scope == SearchScope.All) { - return collectionComplete.HasReferrersAsync(appId, contentId, ct); + return collectionComplete.HasReferrersAsync(appId, reference, ct); } else { - return collectionPublished.HasReferrersAsync(appId, contentId, ct); + return collectionPublished.HasReferrersAsync(appId, reference, ct); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs index 8e32dbf47..2f7ae61cd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs @@ -14,14 +14,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; public sealed class QueryAsStream : OperationBase { - public override IEnumerable> CreateIndexes() - { - yield return new CreateIndexModel(Index - .Ascending(x => x.IndexedAppId) - .Ascending(x => x.IsDeleted) - .Ascending(x => x.IndexedSchemaId)); - } - public async IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, [EnumeratorCancellation] CancellationToken ct) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs index f0ddbb441..a0ddc16e0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs @@ -150,7 +150,7 @@ internal sealed class QueryByQuery : OperationBase } private static (FilterDefinition, bool) CreateFilter(DomainId appId, IEnumerable schemaIds, ClrQuery? query, - DomainId referenced, RefToken? createdBy) + DomainId reference, RefToken? createdBy) { var filters = new List> { @@ -176,9 +176,9 @@ internal sealed class QueryByQuery : OperationBase isDefault = false; } - if (referenced != default) + if (reference != default) { - filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced)); + filters.Add(Filter.AnyEq(x => x.ReferencedIds, reference)); isDefault = false; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs index d810e913d..49cb125b2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs @@ -165,7 +165,7 @@ internal sealed class QueryInDedicatedCollection : MongoBase } private static FilterDefinition CreateFilter(ClrQuery? query, - DomainId referenced, RefToken? createdBy) + DomainId reference, RefToken? createdBy) { var filters = new List> { @@ -183,9 +183,9 @@ internal sealed class QueryInDedicatedCollection : MongoBase filters.Add(query.Filter.BuildFilter()); } - if (referenced != default) + if (reference != default) { - filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced)); + filters.Add(Filter.AnyEq(x => x.ReferencedIds, reference)); } if (createdBy != null) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs index 43df890d1..429cadf7a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs @@ -5,7 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Runtime.CompilerServices; using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -21,15 +23,10 @@ internal sealed class QueryReferrers : OperationBase .Ascending(x => x.IsDeleted)); } - public async Task CheckExistsAsync(DomainId appId, DomainId contentId, + public async Task CheckExistsAsync(DomainId appId, DomainId reference, CancellationToken ct) { - var filter = - Filter.And( - Filter.AnyEq(x => x.ReferencedIds, contentId), - Filter.Eq(x => x.IndexedAppId, appId), - Filter.Ne(x => x.IsDeleted, true), - Filter.Ne(x => x.Id, contentId)); + var filter = BuildFilter(appId, reference); var hasReferrerAsync = await Collection.Find(filter).Only(x => x.Id) @@ -37,4 +34,30 @@ internal sealed class QueryReferrers : OperationBase return hasReferrerAsync; } + + public async IAsyncEnumerable StreamReferencing(DomainId appId, DomainId reference, int take, + [EnumeratorCancellation] CancellationToken ct) + { + var filter = BuildFilter(appId, reference); + + using (var cursor = await Collection.Find(filter).Limit(take).ToCursorAsync(ct)) + { + while (await cursor.MoveNextAsync(ct)) + { + foreach (var entity in cursor.Current) + { + yield return entity; + } + } + } + } + + private static FilterDefinition BuildFilter(DomainId appId, DomainId reference) + { + return Filter.And( + Filter.AnyEq(x => x.ReferencedIds, reference), + Filter.Eq(x => x.IndexedAppId, appId), + Filter.Ne(x => x.IsDeleted, true), + Filter.Ne(x => x.Id, reference)); + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs index c69da4167..a14d002a7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs @@ -11,6 +11,7 @@ using NodaTime; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Reflection; @@ -80,8 +81,10 @@ public sealed class MongoRuleEventEntity : IRuleEventEntity get => JobId; } - public static MongoRuleEventEntity FromJob(RuleJob job, Instant? nextAttempt) + public static MongoRuleEventEntity FromJob(RuleEventWrite item) { + var (job, nextAttempt, error) = item; + var entity = new MongoRuleEventEntity { Job = job, @@ -91,6 +94,14 @@ public sealed class MongoRuleEventEntity : IRuleEventEntity SimpleMapper.Map(job, entity); + if (nextAttempt == null) + { + entity.JobResult = RuleJobResult.Failed; + entity.LastDump = error?.ToString(); + entity.LastModified = job.Created; + entity.Result = RuleResult.Failed; + } + return entity; } } 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 eee64024b..92c2a37d2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -7,7 +7,6 @@ using MongoDB.Driver; using NodaTime; -using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Rules; @@ -19,12 +18,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules; public sealed class MongoRuleEventRepository : MongoRepositoryBase, IRuleEventRepository, IDeleter { - private readonly MongoRuleStatisticsCollection statisticsCollection; - public MongoRuleEventRepository(IMongoDatabase database) : base(database) { - statisticsCollection = new MongoRuleStatisticsCollection(database); } protected override string CollectionName() @@ -35,8 +31,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase collection, CancellationToken ct) { - await statisticsCollection.InitializeAsync(ct); - await collection.Indexes.CreateManyAsync(new[] { new CreateIndexModel( @@ -58,8 +52,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase x.AppId, app.Id), ct); } @@ -106,14 +98,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase x.JobId == id, Update.Set(x => x.NextAttempt, nextAttempt), cancellationToken: ct); } - public async Task EnqueueAsync(RuleJob job, Instant? nextAttempt, - CancellationToken ct = default) - { - var entity = MongoRuleEventEntity.FromJob(job, nextAttempt); - - await Collection.InsertOneIfNotExistsAsync(entity, ct); - } - public Task CancelByEventAsync(DomainId id, CancellationToken ct = default) { @@ -150,14 +134,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase x.JobId == job.Id, Update .Set(x => x.Result, update.ExecutionResult) @@ -168,22 +144,14 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase jobs, CancellationToken ct = default) { - if (update.ExecutionResult == RuleResult.Success) - { - await statisticsCollection.IncrementSuccessAsync(job.AppId, job.RuleId, update.Finished, ct); - } - else + var entities = jobs.Select(MongoRuleEventEntity.FromJob).ToList(); + + if (entities.Count > 0) { - await statisticsCollection.IncrementFailedAsync(job.AppId, job.RuleId, update.Finished, ct); + await Collection.InsertManyAsync(entities, InsertUnordered, ct); } } - - public Task> QueryStatisticsByAppAsync(DomainId appId, - CancellationToken ct = default) - { - return statisticsCollection.QueryByAppAsync(appId, ct); - } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs deleted file mode 100644 index 27beb92c4..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs +++ /dev/null @@ -1,89 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using MongoDB.Bson.Serialization; -using MongoDB.Driver; -using NodaTime; -using Squidex.Domain.Apps.Entities.Rules.Repositories; -using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Rules; - -public sealed class MongoRuleStatisticsCollection : MongoRepositoryBase -{ - static MongoRuleStatisticsCollection() - { - BsonClassMap.RegisterClassMap(cm => - { - cm.AutoMap(); - - cm.SetIgnoreExtraElements(true); - }); - } - - public MongoRuleStatisticsCollection(IMongoDatabase database) - : base(database) - { - } - - protected override string CollectionName() - { - return "RuleStatistics"; - } - - protected override Task SetupCollectionAsync(IMongoCollection collection, - CancellationToken ct) - { - return collection.Indexes.CreateOneAsync( - new CreateIndexModel( - Index - .Ascending(x => x.AppId) - .Ascending(x => x.RuleId)), - cancellationToken: ct); - } - - public async Task DeleteAppAsync(DomainId appId, - CancellationToken ct) - { - await Collection.DeleteManyAsync(Filter.Eq(x => x.AppId, appId), ct); - } - - public async Task> QueryByAppAsync(DomainId appId, - CancellationToken ct) - { - var statistics = await Collection.Find(x => x.AppId == appId).ToListAsync(ct); - - return statistics; - } - - public Task IncrementSuccessAsync(DomainId appId, DomainId ruleId, Instant now, - CancellationToken ct) - { - return Collection.UpdateOneAsync( - x => x.AppId == appId && x.RuleId == ruleId, - Update - .Inc(x => x.NumSucceeded, 1) - .Set(x => x.LastExecuted, now) - .SetOnInsert(x => x.AppId, appId) - .SetOnInsert(x => x.RuleId, ruleId), - Upsert, ct); - } - - public Task IncrementFailedAsync(DomainId appId, DomainId ruleId, Instant now, - CancellationToken ct) - { - return Collection.UpdateOneAsync( - x => x.AppId == appId && x.RuleId == ruleId, - Update - .Inc(x => x.NumFailed, 1) - .Set(x => x.LastExecuted, now) - .SetOnInsert(x => x.AppId, appId) - .SetOnInsert(x => x.RuleId, ruleId), - Upsert, ct); - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs index 3ad9bf340..1fd191862 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -65,7 +66,7 @@ public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscript } } - public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, [EnumeratorCancellation] CancellationToken ct) { yield return await CreateEnrichedEventsCoreAsync(@event, ct); @@ -121,11 +122,11 @@ public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscript return result; } - public bool Trigger(EnrichedEvent @event, RuleContext context) + public bool Trigger(EnrichedEvent @event, RuleTrigger trigger) { - var trigger = (AssetChangedTriggerV2)context.Rule.Trigger; + var assetTrigger = (AssetChangedTriggerV2)trigger; - if (string.IsNullOrWhiteSpace(trigger.Condition)) + if (string.IsNullOrWhiteSpace(assetTrigger.Condition)) { return true; } @@ -136,6 +137,6 @@ public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscript Event = @event }; - return scriptEngine.Evaluate(vars, trigger.Condition); + return scriptEngine.Evaluate(vars, assetTrigger.Condition); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetStats.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetStats.cs deleted file mode 100644 index dd4537efb..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetStats.cs +++ /dev/null @@ -1,12 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -#pragma warning disable SA1313 // Parameter names should begin with lower-case letter - -namespace Squidex.Domain.Apps.Entities.Assets; - -public sealed record AssetStats(DateTime Date, long TotalCount, long TotalSize); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs index 0d88e2bb0..e5164e4a7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs @@ -7,7 +7,6 @@ using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Billing; using Squidex.Infrastructure.States; #pragma warning disable CS0649 @@ -17,9 +16,9 @@ namespace Squidex.Domain.Apps.Entities.Assets; public partial class AssetUsageTracker : IDeleter { private readonly IAssetLoader assetLoader; + private readonly IAssetUsageTracker assetUsageTracker; private readonly ISnapshotStore store; private readonly ITagService tagService; - private readonly IUsageGate usageGate; [CollectionName("Index_TagHistory")] public sealed class State @@ -27,11 +26,14 @@ public partial class AssetUsageTracker : IDeleter public HashSet? Tags { get; set; } } - public AssetUsageTracker(IUsageGate usageGate, IAssetLoader assetLoader, ITagService tagService, + public AssetUsageTracker( + IAssetLoader assetLoader, + IAssetUsageTracker assetUsageTracker, + ITagService tagService, ISnapshotStore store) { - this.usageGate = usageGate; this.assetLoader = assetLoader; + this.assetUsageTracker = assetUsageTracker; this.tagService = tagService; this.store = store; @@ -41,6 +43,6 @@ public partial class AssetUsageTracker : IDeleter Task IDeleter.DeleteAppAsync(IAppEntity app, CancellationToken ct) { - return usageGate.DeleteAssetUsageAsync(app.Id, ct); + return assetUsageTracker.DeleteUsageAsync(app.Id, ct); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs index 9f21c832f..b7251970d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs @@ -52,12 +52,13 @@ public partial class AssetUsageTracker : IEventConsumer // Will not remove data, but reset alls counts to zero. await tagService.ClearAsync(); + // Delete the full usage history, because tracking is incremental. + await assetUsageTracker.DeleteUsageAsync(); + // Also clear the store and cache, because otherwise we would use data from the future when querying old tags. ClearCache(); await store.ClearAsync(); - - await usageGate.DeleteAssetsUsageAsync(); } public async Task On(IEnumerable> events) @@ -185,20 +186,20 @@ public partial class AssetUsageTracker : IEventConsumer switch (@event.Payload) { case AssetCreated assetCreated: - return usageGate.TrackAssetAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1); + return assetUsageTracker.TrackAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1); case AssetUpdated assetUpdated: - return usageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0); + return assetUsageTracker.TrackAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0); case AssetDeleted assetDeleted: - return usageGate.TrackAssetAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1); + return assetUsageTracker.TrackAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1); } return Task.CompletedTask; } - private static DateTime GetDate(Envelope @event) + private static DateOnly GetDate(Envelope @event) { - return @event.Headers.Timestamp().ToDateTimeUtc().Date; + return @event.Headers.Timestamp().ToDateOnly(); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs index bc95ca97e..dd6d8c24f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs @@ -7,19 +7,35 @@ using Squidex.Infrastructure; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter +#pragma warning disable MA0048 // File name must match type name + namespace Squidex.Domain.Apps.Entities.Assets; public interface IAssetUsageTracker { - Task> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate, + Task> QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct = default); + + Task> QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct = default); + + Task GetTotalByAppAsync(DomainId appId, CancellationToken ct = default); - Task> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate, + Task GetTotalByTeamAsync(DomainId teamId, CancellationToken ct = default); - Task GetTotalSizeByAppAsync(DomainId appId, + Task TrackAsync(DomainId appId, DateOnly date, long fileSize, long count, CancellationToken ct = default); - Task GetTotalSizeByTeamAsync(DomainId teamId, + Task DeleteUsageAsync(DomainId appId, + CancellationToken ct = default); + + Task DeleteUsageAsync( CancellationToken ct = default); } + +public record struct AssetStats(DateOnly Date, AssetCounters Counters); + +public record struct AssetCounters(long TotalSize, long TotalAssets); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs index f91dc1e2b..c9528dd24 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs @@ -13,19 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Billing; public interface IUsageGate { - Task IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, + Task IsBlockedAsync(IAppEntity app, string? clientId, DateOnly date, CancellationToken ct = default); - Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes, - CancellationToken ct = default); - - Task TrackAssetAsync(DomainId appId, DateTime date, long fileSize, long count, - CancellationToken ct = default); - - Task DeleteAssetUsageAsync(DomainId appId, - CancellationToken ct = default); - - Task DeleteAssetsUsageAsync( + Task TrackRequestAsync(IAppEntity app, string? clientId, DateOnly date, double costs, long elapsedMs, long bytes, CancellationToken ct = default); Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app, bool canCache, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Assets.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Assets.cs new file mode 100644 index 000000000..c1a714f65 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Assets.cs @@ -0,0 +1,145 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.UsageTracking; + +namespace Squidex.Domain.Apps.Entities.Billing; + +public sealed partial class UsageGate : IAssetUsageTracker +{ + public static class AssetsKeys + { + public const string TotalAssets = nameof(AssetCounters.TotalAssets); + public const string TotalSize = nameof(AssetCounters.TotalSize); + } + + Task IAssetUsageTracker.DeleteUsageAsync(DomainId appId, + CancellationToken ct) + { + // Do not delete the team, as this is only called when an app is deleted. + return usageTracker.DeleteAsync(AppAssetsKey(appId), ct); + } + + Task IAssetUsageTracker.DeleteUsageAsync( + CancellationToken ct) + { + // Use a well defined prefix query for the deletion to improve performance. + return usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_[A-Za-z]+Assets", ct); + } + + Task IAssetUsageTracker.GetTotalByAppAsync(DomainId appId, + CancellationToken ct) + { + return GetTotalForAssetsAsync(AppAssetsKey(appId), ct); + } + + Task IAssetUsageTracker.GetTotalByTeamAsync(DomainId teamId, + CancellationToken ct) + { + return GetTotalForAssetsAsync(TeamAssetsKey(teamId), ct); + } + + Task> IAssetUsageTracker.QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct) + { + return QueryForAssetsAsync(AppAssetsKey(appId), fromDate, toDate, ct); + } + + Task> IAssetUsageTracker.QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct) + { + return QueryForAssetsAsync(TeamAssetsKey(teamId), fromDate, toDate, ct); + } + + private async Task GetTotalForAssetsAsync(string key, + CancellationToken ct) + { + var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct); + + return GetAssetCounters(counters); + } + + private async Task> QueryForAssetsAsync(string key, DateOnly fromDate, DateOnly toDate, + CancellationToken ct) + { + var result = new List(); + + var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct); + + for (var date = fromDate; date <= toDate; date = date.AddDays(1)) + { + var aggregated = default(AssetCounters); + + foreach (var (_, byCategory) in usages) + { + foreach (var (counterDate, counters) in byCategory) + { + if (counterDate == date) + { + var currentCounters = GetAssetCounters(counters); + + aggregated.TotalSize += currentCounters.TotalSize; + aggregated.TotalAssets += currentCounters.TotalAssets; + } + } + } + + result.Add(new AssetStats(date, aggregated)); + } + + return result; + } + + async Task IAssetUsageTracker.TrackAsync(DomainId appId, DateOnly date, long fileSize, long count, + CancellationToken ct) + { + var counters = new Counters + { + [AssetsKeys.TotalSize] = fileSize, + [AssetsKeys.TotalAssets] = count + }; + + var appKey = AppAssetsKey(appId); + + var tasks = new List + { + usageTracker.TrackAsync(date, appKey, null, counters, ct), + usageTracker.TrackAsync(SummaryDate, appKey, null, counters, ct) + }; + + var (_, _, teamId) = await GetPlanForAppAsync(appId, true, ct); + + if (teamId != null) + { + var teamKey = TeamAssetsKey(teamId.Value); + + tasks.Add(usageTracker.TrackAsync(date, teamKey, appId.ToString(), counters, ct)); + tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, appId.ToString(), counters, ct)); + } + + await Task.WhenAll(tasks); + } + + private static AssetCounters GetAssetCounters(Counters counters) + { + return new AssetCounters( + counters.GetInt64(AssetsKeys.TotalSize), + counters.GetInt64(AssetsKeys.TotalAssets)); + } + + private static string AppAssetsKey(DomainId appId) + { + return $"{appId}_Assets"; + } + + private static string TeamAssetsKey(DomainId teamId) + { + return $"{teamId}_TeamAssets"; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs new file mode 100644 index 000000000..79cfba15e --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs @@ -0,0 +1,140 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Infrastructure; +using Squidex.Infrastructure.UsageTracking; + +namespace Squidex.Domain.Apps.Entities.Billing; + +public sealed partial class UsageGate : IRuleUsageTracker +{ + public static class RulesKeys + { + public const string TotalCreated = nameof(RuleCounters.TotalCreated); + public const string TotalSucceeded = nameof(RuleCounters.TotalSucceeded); + public const string TotalFailed = nameof(RuleCounters.TotalFailed); + } + + Task IRuleUsageTracker.DeleteUsageAsync(DomainId appId, + CancellationToken ct) + { + // Use a well defined prefix query for the deletion to improve performance. + return usageTracker.DeleteAsync(AppRulesKey(appId), ct); + } + + Task> IRuleUsageTracker.QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct) + { + return QueryForRulesAsync(AppRulesKey(appId), fromDate, toDate, ct); + } + + Task> IRuleUsageTracker.QueryByTeamAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct) + { + return QueryForRulesAsync(TeamRulesKey(appId), fromDate, toDate, ct); + } + + async Task> IRuleUsageTracker.GetTotalByAppAsync(DomainId appId, + CancellationToken ct) + { + var result = new Dictionary(); + + var counters = await usageTracker.QueryAsync(AppRulesKey(appId), SummaryDate, SummaryDate, ct); + + foreach (var (category, byCategory) in counters) + { + if (byCategory.Count > 0) + { + result[DomainId.Create(category)] = GetRuleCounters(byCategory[0].Item2); + } + } + + return result; + } + + private async Task> QueryForRulesAsync(string key, DateOnly fromDate, DateOnly toDate, + CancellationToken ct) + { + var result = new List(); + + var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct); + + for (var date = fromDate; date <= toDate; date = date.AddDays(1)) + { + var aggregated = default(RuleCounters); + + foreach (var (_, byCategory) in usages) + { + foreach (var (counterDate, counters) in byCategory) + { + if (counterDate == date) + { + var currentCounters = GetRuleCounters(counters); + + aggregated.TotalCreated += currentCounters.TotalCreated; + aggregated.TotalSucceeded += currentCounters.TotalSucceeded; + aggregated.TotalFailed += currentCounters.TotalFailed; + } + } + } + + result.Add(new RuleStats(date, aggregated)); + } + + return result; + } + + async Task IRuleUsageTracker.TrackAsync(DomainId appId, DomainId ruleId, DateOnly date, int created, int succeeded, int failed, + CancellationToken ct) + { + var counters = new Counters + { + [RulesKeys.TotalCreated] = created, + [RulesKeys.TotalSucceeded] = succeeded, + [RulesKeys.TotalFailed] = failed + }; + + var appKey = AppRulesKey(appId); + + var tasks = new List + { + usageTracker.TrackAsync(date, appKey, ruleId.ToString(), counters, ct), + usageTracker.TrackAsync(SummaryDate, appKey, ruleId.ToString(), counters, ct) + }; + + var (_, _, teamId) = await GetPlanForAppAsync(appId, true, ct); + + if (teamId != null) + { + var teamKey = TeamRulesKey(teamId.Value); + + tasks.Add(usageTracker.TrackAsync(date, teamKey, appId.ToString(), counters, ct)); + tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, appId.ToString(), counters, ct)); + } + + await Task.WhenAll(tasks); + } + + private static RuleCounters GetRuleCounters(Counters counters) + { + return new RuleCounters( + counters.GetInt64(RulesKeys.TotalCreated), + counters.GetInt64(RulesKeys.TotalSucceeded), + counters.GetInt64(RulesKeys.TotalFailed)); + } + + private static string AppRulesKey(DomainId appId) + { + return $"{appId}_Rules"; + } + + private static string TeamRulesKey(DomainId teamId) + { + return $"{teamId}_TeamRules"; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs index bc92668cd..7fac6c658 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Teams; using Squidex.Infrastructure; using Squidex.Infrastructure.UsageTracking; @@ -17,11 +16,9 @@ using Squidex.Messaging; namespace Squidex.Domain.Apps.Entities.Billing; -public sealed class UsageGate : IUsageGate, IAssetUsageTracker +public sealed partial class UsageGate : IUsageGate { - private const string CounterTotalCount = "TotalAssets"; - private const string CounterTotalSize = "TotalSize"; - private static readonly DateTime SummaryDate = default; + private static readonly DateOnly SummaryDate = default; private readonly IApiUsageTracker apiUsageTracker; private readonly IAppProvider appProvider; private readonly IBillingPlans billingPlans; @@ -43,79 +40,7 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker this.usageTracker = usageTracker; } - public Task DeleteAssetUsageAsync(DomainId appId, - CancellationToken ct = default) - { - // Do not delete the team, as this is only called when an app is deleted. - return usageTracker.DeleteAsync(AppAssetsKey(appId), ct); - } - - public Task DeleteAssetsUsageAsync( - CancellationToken ct = default) - { - // Use a well defined prefix query for the deletion to improve performance. - return usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_Assets", ct); - } - - public Task GetTotalSizeByAppAsync(DomainId appId, - CancellationToken ct = default) - { - return GetTotalSizeAsync(AppAssetsKey(appId), ct); - } - - public Task GetTotalSizeByTeamAsync(DomainId teamId, - CancellationToken ct = default) - { - return GetTotalSizeAsync(TeamAssetsKey(teamId), ct); - } - - private async Task GetTotalSizeAsync(string key, - CancellationToken ct) - { - var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct); - - return counters.GetInt64(CounterTotalSize); - } - - public Task> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate, - CancellationToken ct = default) - { - return QueryAsync(AppAssetsKey(appId), fromDate, toDate, ct); - } - - public Task> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate, - CancellationToken ct = default) - { - return QueryAsync(TeamAssetsKey(teamId), fromDate, toDate, ct); - } - - private async Task> QueryAsync(string key, DateTime fromDate, DateTime toDate, - CancellationToken ct) - { - var enriched = new List(); - - var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct); - - if (usages.TryGetValue(usageTracker.FallbackCategory, out var byCategory1)) - { - AddCounters(enriched, byCategory1); - } - - return enriched; - } - - private static void AddCounters(List enriched, List<(DateTime, Counters)> details) - { - foreach (var (date, counters) in details) - { - var totalCount = counters.GetInt64(CounterTotalCount); - var totalSize = counters.GetInt64(CounterTotalSize); - - enriched.Add(new AssetStats(date, totalCount, totalSize)); - } - } - - public async Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes, + public async Task TrackRequestAsync(IAppEntity app, string? clientId, DateOnly date, double costs, long elapsedMs, long bytes, CancellationToken ct = default) { var appId = app.Id.ToString(); @@ -128,7 +53,7 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker await apiUsageTracker.TrackAsync(date, appId, clientId, costs, elapsedMs, bytes, ct); } - public async Task IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, + public async Task IsBlockedAsync(IAppEntity app, string? clientId, DateOnly date, CancellationToken ct = default) { Guard.NotNull(app); @@ -196,7 +121,7 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker return usage > limit * 0.1; } - private static bool IsAboutToBeLocked(DateTime today, long limit, long usage) + private static bool IsAboutToBeLocked(DateOnly today, long limit, long usage) { var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month); @@ -205,36 +130,6 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker return forecasted > limit; } - public async Task TrackAssetAsync(DomainId appId, DateTime date, long fileSize, long count, - CancellationToken ct = default) - { - var counters = new Counters - { - [CounterTotalSize] = fileSize, - [CounterTotalCount] = count - }; - - var appKey = AppAssetsKey(appId); - - var tasks = new List - { - usageTracker.TrackAsync(date, appKey, null, counters, ct), - usageTracker.TrackAsync(SummaryDate, appKey, null, counters, ct) - }; - - var (_, _, teamId) = await GetPlanForAppAsync(appId, true, ct); - - if (teamId != null) - { - var teamKey = TeamAssetsKey(teamId.Value); - - tasks.Add(usageTracker.TrackAsync(date, teamKey, null, counters, ct)); - tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, null, counters, ct)); - } - - await Task.WhenAll(tasks); - } - public Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app, bool canCache, CancellationToken ct = default) { @@ -311,16 +206,6 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker return Task.FromResult((plan, planId)); } - private static string AppAssetsKey(DomainId appId) - { - return $"{appId}_Assets"; - } - - private static string TeamAssetsKey(DomainId appId) - { - return $"{appId}_TeamAssets"; - } - private static string CacheKey(DomainId appId) { return $"{appId}_Plan"; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs index c9f321786..48638d957 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -37,7 +38,7 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler return @event is CommentCreated; } - public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, [EnumeratorCancellation] CancellationToken ct) { var commentCreated = (CommentCreated)@event.Payload; @@ -70,11 +71,11 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler } } - public bool Trigger(EnrichedEvent @event, RuleContext context) + public bool Trigger(EnrichedEvent @event, RuleTrigger trigger) { - var trigger = (CommentTrigger)context.Rule.Trigger; + var commentTrigger = (CommentTrigger)trigger; - if (string.IsNullOrWhiteSpace(trigger.Condition)) + if (string.IsNullOrWhiteSpace(commentTrigger.Condition)) { return true; } @@ -85,6 +86,6 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler Event = @event }; - return scriptEngine.Evaluate(vars, trigger.Condition); + return scriptEngine.Evaluate(vars, commentTrigger.Condition); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index 3a3d24481..8fbd4d8f5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -8,6 +8,7 @@ using System.Runtime.CompilerServices; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -16,6 +17,7 @@ using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; using Squidex.Text; @@ -75,10 +77,48 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri } } - public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, [EnumeratorCancellation] CancellationToken ct) { - yield return await CreateEnrichedEventsCoreAsync(@event, ct); + var enrichedEvent = await CreateEnrichedEventsCoreAsync(@event, ct); + + yield return enrichedEvent; + + if (!context.AllowExtraEvents || + context.MaxEvents == null || + context.MaxEvents <= 0) + { + yield break; + } + + // When the content has just been created, it cannot be referenced by another content. Therefore we can skip it. + if (enrichedEvent.Type == EnrichedContentEventType.Created) + { + yield break; + } + + // This method is only called once per event, therefore we check all rules. + if (!context.Rules.Values.Any(r => TriggerReferences(enrichedEvent, r))) + { + yield break; + } + + var take = context.MaxEvents.Value; + + await foreach (var content in contentRepository.StreamReferencing(context.AppId.Id, enrichedEvent.Id, take, ct)) + { + var result = new EnrichedContentEvent + { + Type = EnrichedContentEventType.ReferenceUpdated + }; + + SimpleMapper.Map(content, result); + + result.Actor = content.LastModifiedBy; + result.Name = $"{content.SchemaId.Name.ToPascalCase()}ReferenceUpdated"; + + yield return result; + } } public async ValueTask CreateEnrichedEventsAsync(Envelope @event, @@ -87,7 +127,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri return await CreateEnrichedEventsCoreAsync(@event, ct); } - private async ValueTask CreateEnrichedEventsCoreAsync(Envelope @event, + private async ValueTask CreateEnrichedEventsCoreAsync(Envelope @event, CancellationToken ct) { var contentEvent = (ContentEvent)@event.Payload; @@ -109,6 +149,9 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri // Use the concrete event to map properties that are not part of app event. SimpleMapper.Map(contentEvent, result); + // This property has another name, so we cannot use the simple mapper. + result.Id = contentEvent.ContentId; + switch (@event.Payload) { case ContentCreated: @@ -173,62 +216,87 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri return null; } - public bool Trigger(Envelope @event, RuleContext context) + public bool Trigger(Envelope @event, RuleTrigger trigger) { - var trigger = (ContentChangedTriggerV2)context.Rule.Trigger; + var typed = (ContentChangedTriggerV2)trigger; + + if (typed.HandleAll) + { + return true; + } + + // Also check for the referenced schemas, because it is needed to query the references. + return MatchesAnySchema(typed.Schemas, @event.Payload) || MatchesAnySchema(typed.ReferencedSchemas, @event.Payload); + } + + public bool Trigger(EnrichedEvent @event, RuleTrigger trigger) + { + var typed = (ContentChangedTriggerV2)trigger; - if (trigger.HandleAll) + if (typed.HandleAll) { return true; } - if (trigger.Schemas != null) + // Only check for the actual schemas, not references schemas as they have already been queried. + return MatchesAnySchema(typed.Schemas, @event); + } + + private bool TriggerReferences(EnrichedEvent @event, Rule rule) + { + var trigger = (ContentChangedTriggerV2)rule.Trigger; + + return MatchesAnySchema(trigger.ReferencedSchemas, @event); + } + + private bool MatchesAnySchema(ReadonlyList? schemas, EnrichedEvent @event) + { + if (schemas == null) { - var contentEvent = (ContentEvent)@event.Payload; + return false; + } - foreach (var schema in trigger.Schemas) + var contentEvent = (EnrichedContentEvent)@event; + + foreach (var schema in schemas) + { + // Check for the conditions once to improve performance. + if (MatchsSchema(schema, contentEvent.SchemaId) && MatchsCondition(schema, contentEvent)) { - if (MatchsSchema(schema, contentEvent.SchemaId)) - { - return true; - } + return true; } } return false; } - public bool Trigger(EnrichedEvent @event, RuleContext context) + private bool MatchesAnySchema(ReadonlyList? schemas, AppEvent @event) { - var trigger = (ContentChangedTriggerV2)context.Rule.Trigger; - - if (trigger.HandleAll) + if (schemas == null) { - return true; + return false; } - if (trigger.Schemas != null) - { - var contentEvent = (EnrichedContentEvent)@event; + var contentEvent = (ContentEvent)@event; - foreach (var schema in trigger.Schemas) + foreach (var schema in schemas) + { + // Check for the conditions once to improve performance. + if (MatchsSchema(schema, contentEvent.SchemaId)) { - if (MatchsSchema(schema, contentEvent.SchemaId) && MatchsCondition(schema, contentEvent)) - { - return true; - } + return true; } } return false; } - private static bool MatchsSchema(ContentChangedTriggerSchemaV2? schema, NamedId schemaId) + private static bool MatchsSchema(SchemaCondition? schema, NamedId schemaId) { return schemaId != null && schemaId.Id == schema?.SchemaId; } - private bool MatchsCondition(ContentChangedTriggerSchemaV2 schema, EnrichedSchemaEventBase @event) + private bool MatchsCondition(SchemaCondition schema, EnrichedSchemaEventBase @event) { if (string.IsNullOrWhiteSpace(schema.Condition)) { 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 720ba24d2..ca70e543c 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,9 @@ public interface IContentRepository IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, CancellationToken ct = default); + IAsyncEnumerable StreamReferencing(DomainId appId, DomainId references, int take, + CancellationToken ct = default); + Task> QueryAsync(IAppEntity app, List schemas, Q q, SearchScope scope, CancellationToken ct = default); @@ -34,7 +37,7 @@ public interface IContentRepository Task FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope, CancellationToken ct = default); - Task HasReferrersAsync(DomainId appId, DomainId contentId, SearchScope scope, + Task HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope, CancellationToken ct = default); Task ResetScheduledAsync(DomainId documentId, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs index 3a01aee54..431420395 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs @@ -93,7 +93,7 @@ public sealed class RuleTriggerValidator : IRuleTriggerVisitor CheckSchemaAsync(ContentChangedTriggerSchemaV2 schema) + private async Task CheckSchemaAsync(SchemaCondition schema) { if (await SchemaProvider(schema.SchemaId) == null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs index 8dc7297ee..752b15d76 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs @@ -117,7 +117,7 @@ public partial class RuleDomainObject : DomainObject SimpleMapper.Map(command, @event); SimpleMapper.Map(Snapshot, @event); - await RuleEnqueuer().EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event)); + await RuleEnqueuer().EnqueueAsync(Snapshot.Id, Snapshot.RuleDef, Envelope.Create(@event)); } private IRuleEnqueuer RuleEnqueuer() diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs index 4d6343a87..fa18d52b8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs @@ -5,15 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using NodaTime; - namespace Squidex.Domain.Apps.Entities.Rules; public interface IEnrichedRuleEntity : IRuleEntity { - int NumSucceeded { get; } - - int NumFailed { get; } + long NumSucceeded { get; } - Instant? LastExecuted { get; set; } + long NumFailed { get; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs index ea6ba645e..b387eb0a4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs @@ -13,5 +13,5 @@ namespace Squidex.Domain.Apps.Entities.Rules; public interface IRuleEnqueuer { - Task EnqueueAsync(Rule rule, DomainId ruleId, Envelope @event); -} \ No newline at end of file + Task EnqueueAsync(DomainId ruleId, Rule rule, Envelope @event); +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleUsageTracker.cs new file mode 100644 index 000000000..b6ac0fedc --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleUsageTracker.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter +#pragma warning disable MA0048 // File name must match type name + +namespace Squidex.Domain.Apps.Entities.Rules; + +public interface IRuleUsageTracker +{ + Task> QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct = default); + + Task> QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate, + CancellationToken ct = default); + + Task> GetTotalByAppAsync(DomainId appId, + CancellationToken ct = default); + + Task TrackAsync(DomainId appId, DomainId ruleId, DateOnly date, int created, int succeeded, int failed, + CancellationToken ct = default); + + Task DeleteUsageAsync(DomainId appId, + CancellationToken ct = default); +} + +public record struct RuleStats(DateOnly Date, RuleCounters Counters); + +public record struct RuleCounters(long TotalCreated, long TotalSucceeded, long TotalFailed); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs index 762098673..777152064 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs @@ -25,7 +25,7 @@ public sealed class ManualTriggerHandler : IRuleTriggerHandler return appEvent is RuleManuallyTriggered; } - public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, [EnumeratorCancellation] CancellationToken ct) { var result = new EnrichedManualEvent(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs index 19473236b..57f04a3de 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Reflection; @@ -14,13 +13,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries; public sealed class RuleEnricher : IRuleEnricher { - private readonly IRuleEventRepository ruleEventRepository; + private readonly IRuleUsageTracker ruleUsageTracker; private readonly IRequestCache requestCache; - public RuleEnricher(IRuleEventRepository ruleEventRepository, IRequestCache requestCache) + public RuleEnricher(IRuleUsageTracker ruleUsageTracker, IRequestCache requestCache) { - this.ruleEventRepository = ruleEventRepository; - + this.ruleUsageTracker = ruleUsageTracker; this.requestCache = requestCache; } @@ -53,22 +51,20 @@ public sealed class RuleEnricher : IRuleEnricher foreach (var group in results.GroupBy(x => x.AppId.Id)) { - var statistics = await ruleEventRepository.QueryStatisticsByAppAsync(group.Key, ct); + var statistics = await ruleUsageTracker.GetTotalByAppAsync(group.Key, ct); foreach (var rule in group) { requestCache.AddDependency(rule.UniqueId, rule.Version); - var statistic = statistics.FirstOrDefault(x => x.RuleId == rule.Id); - - if (statistic != null) + if (statistics.TryGetValue(rule.Id, out var statistic)) { - rule.LastExecuted = statistic.LastExecuted; - rule.NumFailed = statistic.NumFailed; - rule.NumSucceeded = statistic.NumSucceeded; - - requestCache.AddDependency(rule.LastExecuted); + rule.NumFailed = statistic.TotalFailed; + rule.NumSucceeded = statistic.TotalSucceeded; } + + requestCache.AddDependency(rule.NumFailed); + requestCache.AddDependency(rule.NumSucceeded); } } 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 4790c9647..4e8cce12d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -6,39 +6,22 @@ // ========================================================================== using NodaTime; -using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Infrastructure; +#pragma warning disable MA0048 // File name must match type name +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Domain.Apps.Entities.Rules.Repositories; +public record struct RuleEventWrite(RuleJob Job, Instant? NextAttempt = null, Exception? Error = null); + public interface IRuleEventRepository { - async Task EnqueueAsync(RuleJob job, Exception? ex, - CancellationToken ct = default) - { - if (ex != null) - { - await EnqueueAsync(job, (Instant?)null, ct); - - await UpdateAsync(job, new RuleJobUpdate - { - JobResult = RuleJobResult.Failed, - ExecutionResult = RuleResult.Failed, - ExecutionDump = ex.ToString(), - Finished = job.Created - }, ct); - } - else - { - await EnqueueAsync(job, job.Created, ct); - } - } - Task UpdateAsync(RuleJob job, RuleJobUpdate update, CancellationToken ct = default); - Task EnqueueAsync(RuleJob job, Instant? nextAttempt, + Task EnqueueAsync(List jobs, CancellationToken ct = default); Task EnqueueAsync(DomainId id, Instant nextAttempt, @@ -56,9 +39,6 @@ public interface IRuleEventRepository Task QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default); - Task> QueryStatisticsByAppAsync(DomainId appId, - CancellationToken ct = default); - Task> QueryByAppAsync(DomainId appId, DomainId? ruleId = null, int skip = 0, int take = 20, CancellationToken ct = default); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs deleted file mode 100644 index 89f17d2a4..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs +++ /dev/null @@ -1,24 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NodaTime; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Rules.Repositories; - -public sealed class RuleStatistics -{ - public DomainId AppId { get; set; } - - public DomainId RuleId { get; set; } - - public int NumSucceeded { get; set; } - - public int NumFailed { get; set; } - - public Instant? LastExecuted { get; set; } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs index 258607af6..6b689a416 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs @@ -25,6 +25,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess private readonly ITargetBlock requestBlock; private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleService ruleService; + private readonly IRuleUsageTracker ruleUsageTracker; private readonly ILogger log; private CompletionTimer timer; @@ -32,11 +33,13 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess public RuleDequeuerWorker( IRuleService ruleService, + IRuleUsageTracker ruleUsageTracker, IRuleEventRepository ruleEventRepository, ILogger log) { this.ruleEventRepository = ruleEventRepository; this.ruleService = ruleService; + this.ruleUsageTracker = ruleUsageTracker; this.log = log; requestBlock = @@ -109,10 +112,16 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess if (response.Status == RuleResult.Failed) { + await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 0, 1); + log.LogWarning(response.Exception, "Failed to execute rule event with rule id {ruleId}/{description}.", @event.Job.RuleId, @event.Job.Description); } + else + { + await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 1, 0); + } } catch (Exception ex) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index 9afaaff40..83dd41fce 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Entities.Rules; @@ -22,11 +23,18 @@ public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer { private readonly IMemoryCache cache; private readonly IRuleEventRepository ruleEventRepository; + private readonly IRuleUsageTracker ruleUsageTracker; private readonly IRuleService ruleService; private readonly ILogger log; private readonly IAppProvider appProvider; private readonly ILocalCache localCache; private readonly TimeSpan cacheDuration; + private readonly int maxExtraEvents; + + public int BatchSize + { + get => 200; + } public string Name { @@ -37,6 +45,7 @@ public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer IAppProvider appProvider, IRuleEventRepository ruleEventRepository, IRuleService ruleService, + IRuleUsageTracker ruleUsageTracker, IOptions options, ILogger log) { @@ -44,53 +53,77 @@ public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer this.cache = cache; this.cacheDuration = options.Value.RulesCacheDuration; this.ruleEventRepository = ruleEventRepository; + this.ruleUsageTracker = ruleUsageTracker; this.ruleService = ruleService; this.log = log; this.localCache = localCache; + this.maxExtraEvents = options.Value.MaxEnrichedEvents; } - public async Task EnqueueAsync(Rule rule, DomainId ruleId, Envelope @event) + public async Task EnqueueAsync(DomainId ruleId, Rule rule, Envelope @event) { Guard.NotNull(rule); Guard.NotNull(@event, nameof(@event)); - var ruleContext = new RuleContext + if (@event.Payload is not AppEvent appEvent) { - Rule = rule, - RuleId = ruleId - }; - - var jobs = ruleService.CreateJobsAsync(@event, ruleContext); + return; + } - await foreach (var job in jobs) + var context = new RulesContext { - // We do not want to handle disabled rules in the normal flow. - if (job.Job != null && job.SkipReason is SkipReason.None or SkipReason.Failed) + AppId = appEvent.AppId, + IncludeSkipped = false, + IncludeStale = false, + Rules = new Dictionary { - log.LogInformation("Adding rule job {jobId} for Rule(action={ruleAction}, trigger={ruleTrigger})", job.Job.Id, - rule.Action.GetType().Name, rule.Trigger.GetType().Name); + [ruleId] = rule + }.ToReadonlyDictionary() + }; - await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError); - } + // Write in batches of 100 items for better performance. Dispose completes the last write. + await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, log); + + await foreach (var result in ruleService.CreateJobsAsync(@event, context)) + { + await batch.WriteAsync(result); } } - public async Task On(Envelope @event) + public async Task On(IEnumerable> events) { - if (@event.Headers.Restored()) + using (localCache.StartContext()) { - return; - } + // Write in batches of 100 items for better performance. Dispose completes the last write. + await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, log); - if (@event.Payload is AppEvent appEvent) - { - using (localCache.StartContext()) + foreach (var @event in events) { + if (@event.Headers.Restored()) + { + continue; + } + + if (@event.Payload is not AppEvent appEvent) + { + continue; + } + var rules = await GetRulesAsync(appEvent.AppId.Id); - foreach (var ruleEntity in rules) + var context = new RulesContext + { + AppId = appEvent.AppId, + AllowExtraEvents = maxExtraEvents > 0, + IncludeSkipped = false, + IncludeStale = false, + Rules = rules.ToDictionary(x => x.Id, x => x.RuleDef).ToReadonlyDictionary(), + MaxEvents = maxExtraEvents + }; + + await foreach (var result in ruleService.CreateJobsAsync(@event, context)) { - await EnqueueAsync(ruleEntity.RuleDef, ruleEntity.Id, @event); + await batch.WriteAsync(result); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs index 1e017544b..e50638af6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs @@ -33,9 +33,9 @@ public sealed class RuleEntity : IEnrichedRuleEntity public bool IsDeleted { get; set; } - public int NumSucceeded { get; set; } + public long NumSucceeded { get; set; } - public int NumFailed { get; set; } + public long NumFailed { get; set; } public Instant? LastExecuted { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleQueueWriter.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleQueueWriter.cs new file mode 100644 index 000000000..c868ab08a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleQueueWriter.cs @@ -0,0 +1,90 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Logging; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Rules; + +internal sealed class RuleQueueWriter : IAsyncDisposable +{ + private readonly List writes = new List(); + private readonly IRuleEventRepository ruleEventRepository; + private readonly IRuleUsageTracker ruleUsageTracker; + private readonly ILogger? log; + + public RuleQueueWriter(IRuleEventRepository ruleEventRepository, IRuleUsageTracker ruleUsageTracker, ILogger? log) + { + this.ruleEventRepository = ruleEventRepository; + this.ruleUsageTracker = ruleUsageTracker; + this.log = log; + } + + public async Task WriteAsync(JobResult result) + { + // We do not want to handle events without a job in the normal flow. + if (result.Job == null) + { + return false; + } + + if (result.EnrichmentError != null || result.SkipReason is SkipReason.Failed) + { + writes.Add(new RuleEventWrite(result.Job, Error: result.EnrichmentError)); + } + else + { + writes.Add(new RuleEventWrite(result.Job, result.Job.Created)); + } + + log?.LogInformation("Adding rule job {jobId} for Rule(action={ruleAction}, trigger={ruleTrigger})", + result.Job.Id, + result.Rule.Action.GetType().Name, + result.Rule.Trigger.GetType().Name); + + var totalFailure = result.SkipReason == SkipReason.Failed ? 1 : 0; + var totalCreated = 1; + + // Unfortunately we cannot write in batches here, because the result could be from multiple rules. + await ruleUsageTracker.TrackAsync(result.Job.AppId, result.RuleId, result.Job.Created.ToDateOnly(), totalCreated, 0, totalFailure); + + if (writes.Count >= 100) + { + await FlushCoreAsync(); + return true; + } + + return false; + } + + public async Task FlushAsync() + { + if (writes.Count > 0) + { + await FlushCoreAsync(); + return true; + } + + return false; + } + + public async ValueTask DisposeAsync() + { + if (writes.Count > 0) + { + await FlushCoreAsync(); + } + } + + private async Task FlushCoreAsync() + { + await ruleEventRepository.EnqueueAsync(writes, default); + writes.Clear(); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs index 1cb19af59..efd5edefd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.States; using Squidex.Messaging; @@ -51,13 +52,15 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService { Guard.NotNull(rule); - var context = new RuleContext + var context = new RulesContext { AppId = appId, - Rule = rule, - RuleId = ruleId, IncludeSkipped = true, - IncludeStale = true + IncludeStale = true, + Rules = new Dictionary + { + [ruleId] = rule + }.ToReadonlyDictionary() }; var simulatedEvents = new List(MaxSimulatedEvents); @@ -68,30 +71,35 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService { var @event = eventFormatter.ParseIfKnown(storedEvent); - if (@event?.Payload is AppEvent appEvent) + if (@event?.Payload is not AppEvent appEvent) + { + continue; + } + + // Also create jobs for rules with failing conditions because we want to show them in th table. + await foreach (var job in ruleService.CreateJobsAsync(@event, context, ct).Take(MaxSimulatedEvents).WithCancellation(ct)) { - // Also create jobs for rules with failing conditions because we want to show them in th table. - await foreach (var result in ruleService.CreateJobsAsync(@event, context, ct).WithCancellation(ct)) + var eventName = job.Job?.EventName; + + if (string.IsNullOrWhiteSpace(eventName)) { - var eventName = result.Job?.EventName; - - if (string.IsNullOrWhiteSpace(eventName)) - { - eventName = ruleService.GetName(appEvent); - } - - simulatedEvents.Add(new SimulatedRuleEvent - { - ActionData = result.Job?.ActionData, - ActionName = result.Job?.ActionName, - EnrichedEvent = result.EnrichedEvent, - Error = result.EnrichmentError?.Message, - Event = @event.Payload, - EventId = @event.Headers.EventId(), - EventName = eventName, - SkipReason = result.SkipReason - }); + eventName = ruleService.GetName(appEvent); } + + var eventId = @event.Headers.EventId(); + + simulatedEvents.Add(new SimulatedRuleEvent + { + ActionData = job.Job?.ActionData, + ActionName = job.Job?.ActionName, + EnrichedEvent = job.EnrichedEvent, + Error = job.EnrichmentError?.Message, + Event = @event.Payload, + EventId = eventId, + EventName = eventName, + SkipReason = job.SkipReason, + UniqueId = $"{eventId}_{job.Offset}", + }); } } @@ -100,16 +108,12 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService public bool CanRunRule(IRuleEntity rule) { - var context = GetContext(rule); - - return context.Rule.Trigger is not ManualTrigger; + return rule.RuleDef.Trigger is not ManualTrigger; } public bool CanRunFromSnapshots(IRuleEntity rule) { - var context = GetContext(rule); - - return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(context); + return rule.RuleDef.Trigger is not ManualTrigger && ruleService.CanCreateSnapshotEvents(rule.RuleDef); } public Task CancelAsync(DomainId appId, @@ -141,14 +145,4 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService return state; } - - private static RuleContext GetContext(IRuleEntity rule) - { - return new RuleContext - { - AppId = rule.AppId, - Rule = rule.RuleDef, - RuleId = rule.Id - }; - } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs index 95bdb6921..7947b4d1e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs @@ -27,6 +27,7 @@ public sealed class RuleRunnerProcessor private readonly ILocalCache localCache; private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleService ruleService; + private readonly IRuleUsageTracker ruleUsageTracker; private readonly ILogger log; private readonly SimpleState state; private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); @@ -78,6 +79,7 @@ public sealed class RuleRunnerProcessor IPersistenceFactory persistenceFactory, IRuleEventRepository ruleEventRepository, IRuleService ruleService, + IRuleUsageTracker ruleUsageTracker, ILogger log) { this.appId = appId; @@ -87,6 +89,7 @@ public sealed class RuleRunnerProcessor this.eventFormatter = eventFormatter; this.ruleEventRepository = ruleEventRepository; this.ruleService = ruleService; + this.ruleUsageTracker = ruleUsageTracker; this.log = log; state = new SimpleState(persistenceFactory, GetType(), appId); @@ -188,7 +191,7 @@ public sealed class RuleRunnerProcessor IncludeSkipped = true }; - if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(run.Context)) + if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(rule.RuleDef)) { await EnqueueFromSnapshotsAsync(run, ct); } @@ -219,22 +222,24 @@ public sealed class RuleRunnerProcessor // We collect errors and allow a few erors before we throw an exception. var errors = 0; - await foreach (var job in ruleService.CreateSnapshotJobsAsync(run.Context, ct)) + // Write in batches of 100 items for better performance. Using completes the last write. + await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null); + + await foreach (var result in ruleService.CreateSnapshotJobsAsync(run.Context, ct)) { - if (job.Job != null && job.SkipReason is SkipReason.None or SkipReason.Disabled) - { - await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError, ct); - } - else if (job.EnrichmentError != null) + await batch.WriteAsync(result); + + if (result.EnrichmentError != null) { errors++; + // We accept a few errors and stop the process if there are too many errors. if (errors >= MaxErrors) { - throw job.EnrichmentError; + throw result.EnrichmentError; } - log.LogWarning(job.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", run.Context.RuleId); + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.RuleId); } } } @@ -245,44 +250,49 @@ public sealed class RuleRunnerProcessor // We collect errors and allow a few erors before we throw an exception. var errors = 0; + // Write in batches of 100 items for better performance. Using completes the last write. + await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null); + // Use a prefix query so that the storage can use an index for the query. var filter = $"^([a-z]+)\\-{appId}"; await foreach (var storedEvent in eventStore.QueryAllAsync(filter, run.Job.Position, ct: ct)) { - try + var @event = eventFormatter.ParseIfKnown(storedEvent); + + if (@event == null) { - var @event = eventFormatter.ParseIfKnown(storedEvent); + continue; + } + + run.Job.Position = storedEvent.EventPosition; + + await foreach (var result in ruleService.CreateJobsAsync(@event, run.Context.ToRulesContext(), ct)) + { + if (await batch.WriteAsync(result)) + { + // Update the process when something has been written. + await state.WriteAsync(ct); + } - if (@event != null) + if (result.EnrichmentError != null) { - var jobs = ruleService.CreateJobsAsync(@event, run.Context, ct); + errors++; - await foreach (var job in jobs.WithCancellation(ct)) + // We accept a few errors and stop the process if there are too many errors. + if (errors >= MaxErrors) { - if (job.Job != null && job.SkipReason is SkipReason.None or SkipReason.Disabled) - { - await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError, ct); - } + throw result.EnrichmentError; } - } - } - catch (Exception ex) - { - errors++; - if (errors >= MaxErrors) - { - throw; + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.RuleId); } - - log.LogWarning(ex, "Failed to run rule with ID {ruleId}, continue with next job.", run.Context.RuleId); - } - finally - { - run.Job.Position = storedEvent.EventPosition; } + } + if (await batch.FlushAsync()) + { + // Update the process when something has been written. await state.WriteAsync(ct); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs index a2e262e17..7e18c9a71 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs @@ -13,6 +13,8 @@ public sealed record SimulatedRuleEvent { public Guid EventId { get; init; } + public string UniqueId { get; init; } + public string EventName { get; init; } public object Event { get; init; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerWorker.cs index 94813b8e9..adf019419 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerWorker.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerWorker.cs @@ -89,27 +89,33 @@ public sealed class UsageTrackerWorker : IMessageHandler, { var from = GetFromDate(today, target.NumDays); - if (target.Triggered == null || target.Triggered < from) + // If we have triggered recently we just stop. + if (target.Triggered != null && target.Triggered >= from) { - var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today, null); - - var limit = target.Limits; + continue; + } - if (costs > limit) - { - target.Triggered = today; + var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today.ToDateOnly(), null); - var @event = new AppUsageExceeded - { - AppId = target.AppId, - CallsCurrent = costs, - CallsLimit = limit, - RuleId = key - }; + var limit = target.Limits; - await state.WriteEventAsync(Envelope.Create(@event)); - } + // If we have not reached our limit, there is nothing to do now. + if (costs <= limit) + { + continue; } + + target.Triggered = today; + + var @event = new AppUsageExceeded + { + AppId = target.AppId, + CallsCurrent = costs, + CallsLimit = limit, + RuleId = key + }; + + await state.WriteEventAsync(Envelope.Create(@event)); } await state.WriteAsync(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs index 76c646ea1..01cdca070 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Events; @@ -25,7 +26,7 @@ public sealed class UsageTriggerHandler : IRuleTriggerHandler return appEvent is AppUsageExceeded; } - public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, [EnumeratorCancellation] CancellationToken ct) { var usageEvent = (AppUsageExceeded)@event.Payload; @@ -42,12 +43,10 @@ public sealed class UsageTriggerHandler : IRuleTriggerHandler yield return result; } - public bool Trigger(Envelope @event, RuleContext context) + public bool Trigger(Envelope @event, RuleTrigger trigger) { - var trigger = (UsageTrigger)context.Rule.Trigger; - var usageEvent = (AppUsageExceeded)@event.Payload; - return usageEvent.CallsLimit >= trigger.Limit; + return usageEvent.CallsLimit >= ((UsageTrigger)trigger).Limit; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs index 7d8ddfdf3..63f7d166b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; @@ -33,7 +34,7 @@ public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler return appEvent is SchemaEvent; } - public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, + public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RulesContext context, [EnumeratorCancellation] CancellationToken ct) { var result = new EnrichedSchemaEvent(); @@ -71,11 +72,11 @@ public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler yield return result; } - public bool Trigger(EnrichedEvent @event, RuleContext context) + public bool Trigger(EnrichedEvent @event, RuleTrigger trigger) { - var trigger = (SchemaChangedTrigger)context.Rule.Trigger; + var schemaTrigger = (SchemaChangedTrigger)trigger; - if (string.IsNullOrWhiteSpace(trigger.Condition)) + if (string.IsNullOrWhiteSpace(schemaTrigger.Condition)) { return true; } @@ -86,6 +87,6 @@ public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler ["event"] = @event }; - return scriptEngine.Evaluate(vars, trigger.Condition); + return scriptEngine.Evaluate(vars, schemaTrigger.Condition); } } diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index 88bf9eeae..87bbbde3f 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -170,25 +170,12 @@ public sealed class GetEventStore : IEventStore, IInitializable await client.DeleteAsync(GetStreamName(streamName), StreamState.Any, cancellationToken: ct); } - public Task AppendAsync(Guid commitId, string streamName, ICollection events, + public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events, CancellationToken ct = default) - { - return AppendEventsInternalAsync(streamName, EtagVersion.Any, events, ct); - } - - public Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events, - CancellationToken ct = default) - { - Guard.GreaterEquals(expectedVersion, -1); - - return AppendEventsInternalAsync(streamName, expectedVersion, events, ct); - } - - private async Task AppendEventsInternalAsync(string streamName, long expectedVersion, ICollection events, - CancellationToken ct) { Guard.NotNullOrEmpty(streamName); Guard.NotNull(events); + Guard.GreaterEquals(expectedVersion, EtagVersion.Any); using (Telemetry.Activities.StartActivity("GetEventStore/AppendEventsInternalAsync")) { diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs index dadf04be7..12d6fe5fa 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs @@ -34,12 +34,6 @@ public partial class MongoEventStore return Collection.DeleteManyAsync(FilterExtensions.ByStream(streamFilter), ct); } - public Task AppendAsync(Guid commitId, string streamName, ICollection events, - CancellationToken ct = default) - { - return AppendAsync(commitId, streamName, EtagVersion.Any, events, ct); - } - public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events, CancellationToken ct = default) { diff --git a/backend/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs b/backend/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs index 5a1f61f43..188d39d92 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs @@ -75,7 +75,7 @@ public sealed class MongoUsageRepository : MongoRepositoryBase, IUsa } else if (updates.Length > 0) { - var writes = new List>(); + var writes = new List>(updates.Length); foreach (var update in updates) { @@ -87,7 +87,10 @@ public sealed class MongoUsageRepository : MongoRepositoryBase, IUsa } } - await Collection.BulkWriteAsync(writes, BulkUnordered, ct); + if (writes.Count > 0) + { + await Collection.BulkWriteAsync(writes, BulkUnordered, ct); + } } } @@ -97,7 +100,7 @@ public sealed class MongoUsageRepository : MongoRepositoryBase, IUsa var update = Update .SetOnInsert(x => x.Key, usageUpdate.Key) - .SetOnInsert(x => x.Date, usageUpdate.Date) + .SetOnInsert(x => x.Date, usageUpdate.Date.ToDateTime(default)) .SetOnInsert(x => x.Category, usageUpdate.Category); foreach (var (key, value) in usageUpdate.Counters) @@ -110,11 +113,14 @@ public sealed class MongoUsageRepository : MongoRepositoryBase, IUsa return (filter, update); } - public async Task> QueryAsync(string key, DateTime fromDate, DateTime toDate, + public async Task> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default) { - var entities = await Collection.Find(x => x.Key == key && x.Date >= fromDate && x.Date <= toDate).ToListAsync(ct); + var dateTimeFrom = fromDate.ToDateTime(default); + var dateTimeTo = toDate.ToDateTime(default); + + var entities = await Collection.Find(x => x.Key == key && x.Date >= dateTimeFrom && x.Date <= dateTimeTo).ToListAsync(ct); - return entities.Select(x => new StoredUsage(x.Category, x.Date, x.Counters)).ToList(); + return entities.Select(x => new StoredUsage(x.Category, x.Date.ToDateOnly(), x.Counters)).ToList(); } } diff --git a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs index 67d5c3422..7f4f70b1f 100644 --- a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; namespace Squidex.Infrastructure; @@ -422,4 +423,52 @@ public static class CollectionExtensions yield return takenElement.Value; } } + + public static IAsyncEnumerable Catch(this IAsyncEnumerable source, Func> handler) + { + return Core(source, handler); + + static async IAsyncEnumerable Core(IAsyncEnumerable source, Func> handler, + [EnumeratorCancellation] CancellationToken ct = default) + { + var error = default(IEnumerable); + + await using (var e = source.GetAsyncEnumerator(ct)) + { + while (true) + { + TSource c; + + try + { + if (!await e.MoveNextAsync(ct)) + { + break; + } + + c = e.Current; + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + error = handler(ex); + break; + } + + yield return c; + } + } + + if (error != null) + { + foreach (var item in error) + { + yield return item; + } + } + } + } } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs b/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs index 432796be7..994409a60 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs @@ -23,9 +23,6 @@ public interface IEventStore IAsyncEnumerable QueryAllAsync(string? streamFilter = null, string? position = null, int take = int.MaxValue, CancellationToken ct = default); - Task AppendAsync(Guid commitId, string streamName, ICollection events, - CancellationToken ct = default); - Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events, CancellationToken ct = default); diff --git a/backend/src/Squidex.Infrastructure/InstantExtensions.cs b/backend/src/Squidex.Infrastructure/InstantExtensions.cs index 84de30ceb..36565664c 100644 --- a/backend/src/Squidex.Infrastructure/InstantExtensions.cs +++ b/backend/src/Squidex.Infrastructure/InstantExtensions.cs @@ -20,4 +20,14 @@ public static class InstantExtensions { return Instant.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds()); } + + public static DateOnly ToDateOnly(this Instant value) + { + return DateOnly.FromDateTime(value.ToDateTimeUtc()); + } + + public static DateOnly ToDateOnly(this DateTime value) + { + return DateOnly.FromDateTime(value); + } } diff --git a/backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs b/backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs index cb91af556..ba8040fb0 100644 --- a/backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs +++ b/backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs @@ -63,9 +63,9 @@ public static class AsyncHelper await using var timer = new Timer(_ => source.Writer.TryWrite(force)); - async Task TrySendAsync() + async Task TrySendAsync(int minSize = 0) { - if (batch.Count > 0) + if (batch.Count > minSize) { await target.Writer.WriteAsync(batch, ct); @@ -74,7 +74,7 @@ public static class AsyncHelper } } - // Exceptions usually that the process was stopped and the channel closed, therefore we do not catch them. + // Exceptions usually mean that the process was stopped and the channel closed, therefore we do not catch them. await foreach (var item in source.Reader.ReadAllAsync(ct)) { if (ReferenceEquals(item, force)) @@ -84,15 +84,11 @@ public static class AsyncHelper } else if (item is TIn typed) { - // The timeout just with the last event and should push events out if no further events are received. + // The timeout restarts with the last event and should push events out if no further events are received. timer.Change(timeout, Timeout.Infinite); batch.Add(typed); - - if (batch.Count >= batchSize) - { - await TrySendAsync(); - } + await TrySendAsync(batchSize - 1); } } diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs b/backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs index 255421b80..e6b7b58dd 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs @@ -9,4 +9,4 @@ namespace Squidex.Infrastructure.UsageTracking; -public sealed record ApiStats(DateTime Date, long TotalCalls, double AverageElapsedMs, long TotalBytes); +public sealed record ApiStats(DateOnly Date, long TotalCalls, double AverageElapsedMs, long TotalBytes); diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs index 91f217e56..295609ce2 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs @@ -27,7 +27,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker return usageTracker.DeleteAsync(apiKey, ct); } - public async Task GetMonthCallsAsync(string key, DateTime date, string? category, + public async Task GetMonthCallsAsync(string key, DateOnly date, string? category, CancellationToken ct = default) { var apiKey = GetKey(key); @@ -37,7 +37,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker return counters.GetInt64(CounterTotalCalls); } - public async Task GetMonthBytesAsync(string key, DateTime date, string? category, + public async Task GetMonthBytesAsync(string key, DateOnly date, string? category, CancellationToken ct = default) { var apiKey = GetKey(key); @@ -47,7 +47,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker return counters.GetInt64(CounterTotalBytes); } - public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes, + public Task TrackAsync(DateOnly date, string key, string? category, double weight, long elapsedMs, long bytes, CancellationToken ct = default) { var apiKey = GetKey(key); @@ -62,7 +62,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker return usageTracker.TrackAsync(date, apiKey, category, counters, ct); } - public async Task<(ApiStatsSummary, Dictionary> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate, + public async Task<(ApiStatsSummary, Dictionary> Details)> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default) { var apiKey = GetKey(key); @@ -98,7 +98,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker var summaryElapsedAvg = CalculateAverage(summaryCalls, summaryElapsed); - var monthStats = await usageTracker.GetForMonthAsync(apiKey, DateTime.Today, null, ct); + var monthStats = await usageTracker.GetForMonthAsync(apiKey, DateTime.Today.ToDateOnly(), null, ct); var summary = new ApiStatsSummary( summaryElapsedAvg, diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs index 407c7ec3b..6d5d0430f 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs @@ -17,7 +17,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker private readonly IUsageRepository usageRepository; private readonly ILogger log; private readonly CompletionTimer usageTimer; - private ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters> jobs = new ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters>(); + private ConcurrentDictionary<(string Key, string Category, DateOnly Date), Counters> jobs = new ConcurrentDictionary<(string Key, string Category, DateOnly Date), Counters>(); private bool isUpdating; public bool HasPendingJobs => !jobs.IsEmpty || isUpdating; @@ -55,7 +55,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker { isUpdating = true; - var localUsages = Interlocked.Exchange(ref jobs, new ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters>()); + var localUsages = Interlocked.Exchange(ref jobs, new ConcurrentDictionary<(string Key, string Category, DateOnly Date), Counters>()); if (!localUsages.IsEmpty) { @@ -106,7 +106,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker return usageRepository.DeleteByKeyPatternAsync(pattern, ct); } - public Task TrackAsync(DateTime date, string key, string? category, Counters counters, + public Task TrackAsync(DateOnly date, string key, string? category, Counters counters, CancellationToken ct = default) { Guard.NotNullOrEmpty(key); @@ -123,21 +123,21 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker return Task.CompletedTask; } - public async Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate, + public async Task>> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default) { Guard.NotNullOrEmpty(key); ThrowIfDisposed(); - var result = new Dictionary>(); + var result = new Dictionary>(); var usageData = await usageRepository.QueryAsync(key, fromDate, toDate, ct); var usageGroups = usageData.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList()); if (usageGroups.Keys.Count == 0) { - var enriched = new List<(DateTime Date, Counters Counters)>(); + var enriched = new List<(DateOnly Date, Counters Counters)>(); for (var date = fromDate; date <= toDate; date = date.AddDays(1)) { @@ -149,7 +149,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker foreach (var (category, value) in usageGroups) { - var enriched = new List<(DateTime Date, Counters Counters)>(); + var enriched = new List<(DateOnly Date, Counters Counters)>(); for (var date = fromDate; date <= toDate; date = date.AddDays(1)) { @@ -164,16 +164,16 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker return result; } - public Task GetForMonthAsync(string key, DateTime date, string? category, + public Task GetForMonthAsync(string key, DateOnly date, string? category, CancellationToken ct = default) { - var dateFrom = new DateTime(date.Year, date.Month, 1); + var dateFrom = new DateOnly(date.Year, date.Month, 1); var dateTo = dateFrom.AddMonths(1).AddDays(-1); return GetAsync(key, dateFrom, dateTo, category, ct); } - public async Task GetAsync(string key, DateTime fromDate, DateTime toDate, string? category, + public async Task GetAsync(string key, DateOnly fromDate, DateOnly toDate, string? category, CancellationToken ct = default) { Guard.NotNullOrEmpty(key); diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs index 93a969229..f40809821 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs @@ -39,7 +39,7 @@ public sealed class CachingUsageTracker : IUsageTracker return inner.DeleteByKeyPatternAsync(pattern, ct); } - public Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate, + public Task>> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default) { Guard.NotNull(key); @@ -47,7 +47,7 @@ public sealed class CachingUsageTracker : IUsageTracker return inner.QueryAsync(key, fromDate, toDate, ct); } - public Task TrackAsync(DateTime date, string key, string? category, Counters counters, + public Task TrackAsync(DateOnly date, string key, string? category, Counters counters, CancellationToken ct = default) { Guard.NotNull(key); @@ -55,7 +55,7 @@ public sealed class CachingUsageTracker : IUsageTracker return inner.TrackAsync(date, key, category, counters, ct); } - public Task GetForMonthAsync(string key, DateTime date, string? category, + public Task GetForMonthAsync(string key, DateOnly date, string? category, CancellationToken ct = default) { Guard.NotNull(key); @@ -70,7 +70,7 @@ public sealed class CachingUsageTracker : IUsageTracker })!; } - public Task GetAsync(string key, DateTime fromDate, DateTime toDate, string? category, + public Task GetAsync(string key, DateOnly fromDate, DateOnly toDate, string? category, CancellationToken ct = default) { Guard.NotNull(key); diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs index fd24129a8..290ce4bef 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs @@ -12,15 +12,15 @@ public interface IApiUsageTracker Task DeleteAsync(string key, CancellationToken ct = default); - Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes, + Task TrackAsync(DateOnly date, string key, string? category, double weight, long elapsedMs, long bytes, CancellationToken ct = default); - Task GetMonthCallsAsync(string key, DateTime date, string? category, + Task GetMonthCallsAsync(string key, DateOnly date, string? category, CancellationToken ct = default); - Task GetMonthBytesAsync(string key, DateTime date, string? category, + Task GetMonthBytesAsync(string key, DateOnly date, string? category, CancellationToken ct = default); - Task<(ApiStatsSummary, Dictionary> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate, + Task<(ApiStatsSummary, Dictionary> Details)> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default); } diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/IUsageRepository.cs b/backend/src/Squidex.Infrastructure/UsageTracking/IUsageRepository.cs index d65ff1511..2d4f2223a 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/IUsageRepository.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/IUsageRepository.cs @@ -15,7 +15,7 @@ public interface IUsageRepository Task TrackUsagesAsync(UsageUpdate[] updates, CancellationToken ct = default); - Task> QueryAsync(string key, DateTime fromDate, DateTime toDate, + Task> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default); Task DeleteAsync(string key, diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs index 808eb1c00..397b9ecb4 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs @@ -11,16 +11,16 @@ public interface IUsageTracker { string FallbackCategory { get; } - Task TrackAsync(DateTime date, string key, string? category, Counters counters, + Task TrackAsync(DateOnly date, string key, string? category, Counters counters, CancellationToken ct = default); - Task GetForMonthAsync(string key, DateTime date, string? category, + Task GetForMonthAsync(string key, DateOnly date, string? category, CancellationToken ct = default); - Task GetAsync(string key, DateTime fromDate, DateTime toDate, string? category, + Task GetAsync(string key, DateOnly fromDate, DateOnly toDate, string? category, CancellationToken ct = default); - Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate, + Task>> QueryAsync(string key, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default); Task DeleteAsync(string key, diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs b/backend/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs index cd3b12d53..ad93745ff 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs @@ -9,4 +9,4 @@ namespace Squidex.Infrastructure.UsageTracking; -public sealed record StoredUsage(string? Category, DateTime Date, Counters Counters); +public sealed record StoredUsage(string? Category, DateOnly Date, Counters Counters); diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/UsageUpdate.cs b/backend/src/Squidex.Infrastructure/UsageTracking/UsageUpdate.cs index 97dfb8d4e..bcca1b846 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/UsageUpdate.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/UsageUpdate.cs @@ -9,7 +9,7 @@ namespace Squidex.Infrastructure.UsageTracking; public struct UsageUpdate { - public DateTime Date; + public DateOnly Date; public string Key; @@ -17,7 +17,7 @@ public struct UsageUpdate public Counters Counters; - public UsageUpdate(DateTime date, string key, string category, Counters counters) + public UsageUpdate(DateOnly date, string key, string category, Counters counters) { Key = key; Category = category; diff --git a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs index 5c859e842..4c6c3ec06 100644 --- a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs +++ b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs @@ -42,27 +42,27 @@ public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer var app = context.HttpContext.Context().App; - if (app != null) + if (app == null || FilterDefinition.Costs <= 0) { - if (FilterDefinition.Costs > 0) - { - using (Telemetry.Activities.StartActivity("CheckUsage")) - { - var (_, clientId) = context.HttpContext.User.GetClient(); + await next(); + return; + } - var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted); + using (Telemetry.Activities.StartActivity("CheckUsage")) + { + var (_, clientId) = context.HttpContext.User.GetClient(); - if (isBlocked) - { - context.Result = new StatusCodeResult(429); - return; - } - } - } + var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today.ToDateOnly(), context.HttpContext.RequestAborted); - context.HttpContext.Response.Headers.Add("X-Costs", FilterDefinition.Costs.ToString(CultureInfo.InvariantCulture)); + if (isBlocked) + { + context.Result = new StatusCodeResult(429); + return; + } } + context.HttpContext.Response.Headers.Add("X-Costs", FilterDefinition.Costs.ToString(CultureInfo.InvariantCulture)); + await next(); } } diff --git a/backend/src/Squidex.Web/Pipeline/IgnoreCacheFilterAttribute.cs b/backend/src/Squidex.Web/Pipeline/IgnoreCacheFilterAttribute.cs index 3c58b6388..d84b5c9c2 100644 --- a/backend/src/Squidex.Web/Pipeline/IgnoreCacheFilterAttribute.cs +++ b/backend/src/Squidex.Web/Pipeline/IgnoreCacheFilterAttribute.cs @@ -7,6 +7,7 @@ namespace Squidex.Web.Pipeline; +[AttributeUsage(AttributeTargets.All)] public sealed class IgnoreCacheFilterAttribute : Attribute { } diff --git a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs index a8c2fa387..5af2db810 100644 --- a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs @@ -73,9 +73,10 @@ public sealed class UsageMiddleware : IMiddleware if (request.Costs > 0) { - var date = request.Timestamp.ToDateTimeUtc().Date; - - await usageGate.TrackRequestAsync(app, request.UserClientId, date, + await usageGate.TrackRequestAsync( + app, + request.UserClientId, + request.Timestamp.ToDateOnly(), request.Costs, request.ElapsedMs, request.Bytes, diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs index 934a48560..2799e77d0 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs @@ -94,6 +94,7 @@ public static class OpenApiServices CreateArrayMap(JsonObjectType.String), CreateObjectMap(), CreateObjectMap(), + CreateStringMap(JsonFormatStrings.Date), CreateStringMap(), CreateStringMap(JsonFormatStrings.DateTime), CreateStringMap(), diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index f949f8a2f..4b2efb836 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -445,7 +445,7 @@ public sealed class AssetsController : ApiController var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, true, HttpContext.RequestAborted); - var currentSize = await assetUsageTracker.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted); + var (_, currentSize) = await assetUsageTracker.GetTotalByAppAsync(AppId, HttpContext.RequestAborted); if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + file.Length) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs index a953f4a73..ecbd308bc 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs @@ -8,7 +8,6 @@ using Squidex.Areas.Api.Controllers.Rules.Models.Triggers; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; -using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters; @@ -27,33 +26,31 @@ public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger) { - return SimpleMapper.Map(trigger, new AssetChangedRuleTriggerDto()); + return AssetChangedRuleTriggerDto.FromDomain(trigger); } public RuleTriggerDto Visit(CommentTrigger trigger) { - return SimpleMapper.Map(trigger, new CommentRuleTriggerDto()); + return CommentRuleTriggerDto.FromDomain(trigger); } public RuleTriggerDto Visit(ManualTrigger trigger) { - return SimpleMapper.Map(trigger, new ManualRuleTriggerDto()); + return ManualRuleTriggerDto.FromDomain(trigger); } public RuleTriggerDto Visit(SchemaChangedTrigger trigger) { - return SimpleMapper.Map(trigger, new SchemaChangedRuleTriggerDto()); + return SchemaChangedRuleTriggerDto.FromDomain(trigger); } public RuleTriggerDto Visit(UsageTrigger trigger) { - return SimpleMapper.Map(trigger, new UsageRuleTriggerDto()); + return UsageRuleTriggerDto.FromDomain(trigger); } public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger) { - var schemas = trigger.Schemas?.Select(ContentChangedRuleTriggerSchemaDto.FromDomain).ToArray(); - - return new ContentChangedRuleTriggerDto { Schemas = schemas, HandleAll = trigger.HandleAll }; + return ContentChangedRuleTriggerDto.FromDomain(trigger); } } 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 df748a43d..964841ec3 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -76,16 +76,17 @@ public sealed class RuleDto : Resource /// /// The number of completed executions. /// - public int NumSucceeded { get; set; } + public long NumSucceeded { get; set; } /// /// The number of failed executions. /// - public int NumFailed { get; set; } + public long NumFailed { get; set; } /// /// The date and time when the rule was executed the last time. /// + [Obsolete("Removed when migrated to new rule statistics.")] public Instant? LastExecuted { get; set; } public static RuleDto FromDomain(IEnrichedRuleEntity rule, bool canRun, IRuleRunnerService ruleRunnerService, Resources resources) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs index 1ae6bab6d..d6e95e23b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs @@ -20,6 +20,12 @@ public sealed record SimulatedRuleEventDto [Required] public Guid EventId { get; init; } + /// + /// The the unique id of the simulated event. + /// + [Required] + public string UniqueId { get; set; } + /// /// The name of the event. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedRuleTriggerDto.cs index 9e1f98662..1d54df8bc 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedRuleTriggerDto.cs @@ -18,6 +18,11 @@ public sealed class AssetChangedRuleTriggerDto : RuleTriggerDto /// public string? Condition { get; set; } + public static AssetChangedRuleTriggerDto FromDomain(AssetChangedTriggerV2 trigger) + { + return SimpleMapper.Map(trigger, new AssetChangedRuleTriggerDto()); + } + public override RuleTrigger ToTrigger() { return SimpleMapper.Map(this, new AssetChangedTriggerV2()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CommentRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CommentRuleTriggerDto.cs index 36b67c456..1034158c0 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CommentRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CommentRuleTriggerDto.cs @@ -18,6 +18,11 @@ public class CommentRuleTriggerDto : RuleTriggerDto /// public string? Condition { get; set; } + public static CommentRuleTriggerDto FromDomain(CommentTrigger trigger) + { + return SimpleMapper.Map(trigger, new CommentRuleTriggerDto()); + } + public override RuleTrigger ToTrigger() { return SimpleMapper.Map(this, new CommentTrigger()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs index 301746f0b..41f23ce43 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs @@ -8,6 +8,7 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers; @@ -16,17 +17,25 @@ public sealed class ContentChangedRuleTriggerDto : RuleTriggerDto /// /// The schema settings. /// - public ContentChangedRuleTriggerSchemaDto[]? Schemas { get; set; } + public ReadonlyList? Schemas { get; set; } + + /// + /// The schema references. + /// + public ReadonlyList? ReferencedSchemas { get; set; } /// /// Determines whether the trigger should handle all content changes events. /// public bool HandleAll { get; set; } - public override RuleTrigger ToTrigger() + public static ContentChangedRuleTriggerDto FromDomain(ContentChangedTriggerV2 trigger) { - var schemas = Schemas?.Select(x => x.ToTrigger()).ToReadonlyList(); + return SimpleMapper.Map(trigger, new ContentChangedRuleTriggerDto()); + } - return new ContentChangedTriggerV2 { HandleAll = HandleAll, Schemas = schemas }; + public override RuleTrigger ToTrigger() + { + return SimpleMapper.Map(this, new ContentChangedTriggerV2()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs deleted file mode 100644 index 8a9de3abf..000000000 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs +++ /dev/null @@ -1,37 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Rules.Triggers; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers; - -public sealed class ContentChangedRuleTriggerSchemaDto -{ - /// - /// The ID of the schema. - /// - public DomainId SchemaId { get; set; } - - /// - /// Javascript condition when to trigger. - /// - public string? Condition { get; set; } - - public ContentChangedTriggerSchemaV2 ToTrigger() - { - return SimpleMapper.Map(this, new ContentChangedTriggerSchemaV2()); - } - - public static ContentChangedRuleTriggerSchemaDto FromDomain(ContentChangedTriggerSchemaV2 trigger) - { - var result = SimpleMapper.Map(trigger, new ContentChangedRuleTriggerSchemaDto()); - - return result; - } -} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ManualRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ManualRuleTriggerDto.cs index 8f87a3267..b9ef1de29 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ManualRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ManualRuleTriggerDto.cs @@ -13,6 +13,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers; public sealed class ManualRuleTriggerDto : RuleTriggerDto { + public static ManualRuleTriggerDto FromDomain(ManualTrigger trigger) + { + return SimpleMapper.Map(trigger, new ManualRuleTriggerDto()); + } + public override RuleTrigger ToTrigger() { return SimpleMapper.Map(this, new ManualTrigger()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/SchemaChangedRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/SchemaChangedRuleTriggerDto.cs index 906225f6f..e7b354e49 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/SchemaChangedRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/SchemaChangedRuleTriggerDto.cs @@ -18,6 +18,11 @@ public sealed class SchemaChangedRuleTriggerDto : RuleTriggerDto /// public string? Condition { get; set; } + public static SchemaChangedRuleTriggerDto FromDomain(SchemaChangedTrigger trigger) + { + return SimpleMapper.Map(trigger, new SchemaChangedRuleTriggerDto()); + } + public override RuleTrigger ToTrigger() { return SimpleMapper.Map(this, new SchemaChangedTrigger()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs index f12af9bbe..c5a608e56 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs @@ -25,6 +25,11 @@ public sealed class UsageRuleTriggerDto : RuleTriggerDto [LocalizedRange(1, 30)] public int? NumDays { get; set; } + public static UsageRuleTriggerDto FromDomain(UsageTrigger trigger) + { + return SimpleMapper.Map(trigger, new UsageRuleTriggerDto()); + } + public override RuleTrigger ToTrigger() { return SimpleMapper.Map(this, new UsageTrigger()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs index 68a16d136..66dcbfe71 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs @@ -8,7 +8,6 @@ using Squidex.Areas.Api.Controllers.Schemas.Models.Fields; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Schemas.Models.Converters; @@ -20,73 +19,73 @@ internal sealed class FieldPropertiesDtoFactory : IFieldPropertiesVisitor public ReadonlyList? UniqueFields { get; set; } - public override FieldProperties ToProperties() + public static ArrayFieldPropertiesDto FromDomain(ArrayFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new ArrayFieldProperties()); + return SimpleMapper.Map(fieldProperties, new ArrayFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new ArrayFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs index cd7dd6b2a..2c46a4e64 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs @@ -124,10 +124,13 @@ public sealed class AssetsFieldPropertiesDto : FieldPropertiesDto /// public bool AllowDuplicates { get; set; } - public override FieldProperties ToProperties() + public static AssetsFieldPropertiesDto FromDomain(AssetsFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new AssetsFieldProperties()); + return SimpleMapper.Map(fieldProperties, new AssetsFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new AssetsFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/BooleanFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/BooleanFieldPropertiesDto.cs index c50f0377d..6fe9830d3 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/BooleanFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/BooleanFieldPropertiesDto.cs @@ -32,10 +32,13 @@ public sealed class BooleanFieldPropertiesDto : FieldPropertiesDto /// public BooleanFieldEditor Editor { get; set; } - public override FieldProperties ToProperties() + public static BooleanFieldPropertiesDto FromDomain(BooleanFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new BooleanFieldProperties()); + return SimpleMapper.Map(fieldProperties, new BooleanFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new BooleanFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentFieldPropertiesDto.cs index 38f5b6c82..debcf34ef 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentFieldPropertiesDto.cs @@ -19,10 +19,13 @@ public sealed class ComponentFieldPropertiesDto : FieldPropertiesDto /// public ReadonlyList? SchemaIds { get; set; } - public override FieldProperties ToProperties() + public static ComponentFieldPropertiesDto FromDomain(ComponentFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new ComponentFieldProperties()); + return SimpleMapper.Map(fieldProperties, new ComponentFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new ComponentFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentsFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentsFieldPropertiesDto.cs index dfb553dd7..b6b462836 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentsFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentsFieldPropertiesDto.cs @@ -34,10 +34,13 @@ public sealed class ComponentsFieldPropertiesDto : FieldPropertiesDto /// public ReadonlyList? UniqueFields { get; set; } - public override FieldProperties ToProperties() + public static ComponentsFieldPropertiesDto FromDomain(ComponentsFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new ComponentsFieldProperties()); + return SimpleMapper.Map(fieldProperties, new ComponentsFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new ComponentsFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/DateTimeFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/DateTimeFieldPropertiesDto.cs index 99b1ad21a..e6b81c485 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/DateTimeFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/DateTimeFieldPropertiesDto.cs @@ -48,10 +48,13 @@ public sealed class DateTimeFieldPropertiesDto : FieldPropertiesDto /// public DateTimeCalculatedDefaultValue? CalculatedDefaultValue { get; set; } - public override FieldProperties ToProperties() + public static DateTimeFieldPropertiesDto FromDomain(DateTimeFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new DateTimeFieldProperties()); + return SimpleMapper.Map(fieldProperties, new DateTimeFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new DateTimeFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/GeolocationFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/GeolocationFieldPropertiesDto.cs index f013cbaa8..d06063eaa 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/GeolocationFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/GeolocationFieldPropertiesDto.cs @@ -17,10 +17,13 @@ public sealed class GeolocationFieldPropertiesDto : FieldPropertiesDto /// public GeolocationFieldEditor Editor { get; set; } - public override FieldProperties ToProperties() + public static GeolocationFieldPropertiesDto FromDomain(GeolocationFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new GeolocationFieldProperties()); + return SimpleMapper.Map(fieldProperties, new GeolocationFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new GeolocationFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs index 2433d5318..ccdebf7de 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs @@ -17,10 +17,13 @@ public sealed class JsonFieldPropertiesDto : FieldPropertiesDto /// public string? GraphQLSchema { get; set; } - public override FieldProperties ToProperties() + public static JsonFieldPropertiesDto FromDomain(JsonFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new JsonFieldProperties()); + return SimpleMapper.Map(fieldProperties, new JsonFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new JsonFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/NumberFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/NumberFieldPropertiesDto.cs index 35affe91a..3431b4604 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/NumberFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/NumberFieldPropertiesDto.cs @@ -53,10 +53,13 @@ public sealed class NumberFieldPropertiesDto : FieldPropertiesDto /// public NumberFieldEditor Editor { get; set; } - public override FieldProperties ToProperties() + public static NumberFieldPropertiesDto FromDomain(NumberFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new NumberFieldProperties()); + return SimpleMapper.Map(fieldProperties, new NumberFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new NumberFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs index 20148509e..c0ca39339 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs @@ -59,10 +59,13 @@ public sealed class ReferencesFieldPropertiesDto : FieldPropertiesDto /// public ReadonlyList? SchemaIds { get; set; } - public override FieldProperties ToProperties() + public static ReferencesFieldPropertiesDto FromDomain(ReferencesFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new ReferencesFieldProperties()); + return SimpleMapper.Map(fieldProperties, new ReferencesFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new ReferencesFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs index 08d82972f..4823537ea 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs @@ -109,10 +109,13 @@ public sealed class StringFieldPropertiesDto : FieldPropertiesDto /// public StringFieldEditor Editor { get; set; } - public override FieldProperties ToProperties() + public static StringFieldPropertiesDto FromDomain(StringFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new StringFieldProperties()); + return SimpleMapper.Map(fieldProperties, new StringFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new StringFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/TagsFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/TagsFieldPropertiesDto.cs index 21161c6de..a51ec27c6 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/TagsFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/TagsFieldPropertiesDto.cs @@ -48,10 +48,13 @@ public sealed class TagsFieldPropertiesDto : FieldPropertiesDto /// public TagsFieldEditor Editor { get; set; } - public override FieldProperties ToProperties() + public static TagsFieldPropertiesDto FromDomain(TagsFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new TagsFieldProperties()); + return SimpleMapper.Map(fieldProperties, new TagsFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new TagsFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/UIFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/UIFieldPropertiesDto.cs index 8fc904589..a3c1f0e8a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/UIFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/UIFieldPropertiesDto.cs @@ -17,10 +17,13 @@ public sealed class UIFieldPropertiesDto : FieldPropertiesDto /// public UIFieldEditor Editor { get; set; } - public override FieldProperties ToProperties() + public static UIFieldPropertiesDto FromDomain(UIFieldProperties fieldProperties) { - var result = SimpleMapper.Map(this, new UIFieldProperties()); + return SimpleMapper.Map(fieldProperties, new UIFieldPropertiesDto()); + } - return result; + public override FieldProperties ToProperties() + { + return SimpleMapper.Map(this, new UIFieldProperties()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs index cc59315d6..75c4f48c3 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using NodaTime; +using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.UsageTracking; namespace Squidex.Areas.Api.Controllers.Statistics.Models; @@ -15,7 +15,7 @@ public sealed class CallsUsagePerDateDto /// /// The date when the usage was tracked. /// - public LocalDate Date { get; set; } + public DateOnly Date { get; set; } /// /// The total number of API calls. @@ -34,14 +34,6 @@ public sealed class CallsUsagePerDateDto public static CallsUsagePerDateDto FromDomain(ApiStats stats) { - var result = new CallsUsagePerDateDto - { - Date = LocalDate.FromDateTime(DateTime.SpecifyKind(stats.Date, DateTimeKind.Utc)), - TotalBytes = stats.TotalBytes, - TotalCalls = stats.TotalCalls, - AverageElapsedMs = stats.AverageElapsedMs - }; - - return result; + return SimpleMapper.Map(stats, new CallsUsagePerDateDto()); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsagePerDateDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsagePerDateDto.cs index 64b52c530..127b41967 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsagePerDateDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsagePerDateDto.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using NodaTime; using Squidex.Domain.Apps.Entities.Assets; namespace Squidex.Areas.Api.Controllers.Statistics.Models; @@ -15,7 +14,7 @@ public sealed class StorageUsagePerDateDto /// /// The date when the usage was tracked. /// - public LocalDate Date { get; set; } + public DateOnly Date { get; set; } /// /// The number of assets. @@ -31,9 +30,9 @@ public sealed class StorageUsagePerDateDto { var result = new StorageUsagePerDateDto { - Date = LocalDate.FromDateTime(DateTime.SpecifyKind(stats.Date, DateTimeKind.Utc)), - TotalCount = stats.TotalCount, - TotalSize = stats.TotalSize + Date = stats.Date, + TotalCount = stats.Counters.TotalAssets, + TotalSize = stats.Counters.TotalSize }; return result; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs index 8a6ff14a1..4c028e887 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs @@ -29,7 +29,7 @@ public sealed class UsagesController : ApiController private readonly IApiUsageTracker usageTracker; private readonly IAppLogStore usageLog; private readonly IUsageGate usageGate; - private readonly IAssetUsageTracker assetStatsRepository; + private readonly IAssetUsageTracker assetUsageTracker; private readonly IDataProtector dataProtector; private readonly IUrlGenerator urlGenerator; @@ -39,12 +39,12 @@ public sealed class UsagesController : ApiController IApiUsageTracker usageTracker, IAppLogStore usageLog, IUsageGate usageGate, - IAssetUsageTracker assetStatsRepository, + IAssetUsageTracker assetUsageTracker, IUrlGenerator urlGenerator) : base(commandBus) { this.usageLog = usageLog; - this.assetStatsRepository = assetStatsRepository; + this.assetUsageTracker = assetUsageTracker; this.urlGenerator = urlGenerator; this.usageGate = usageGate; this.usageTracker = usageTracker; @@ -83,7 +83,7 @@ public sealed class UsagesController : ApiController // Decrypt the token that has previously been generated. var appId = DomainId.Create(dataProtector.Unprotect(token)); - var fileDate = DateTime.UtcNow.Date; + var fileDate = DateTime.UtcNow; var fileName = $"Usage-{fileDate:yyy-MM-dd}.csv"; var callback = new FileCallback((body, range, ct) => @@ -105,21 +105,14 @@ public sealed class UsagesController : ApiController /// The to date. /// API call returned.. /// App not found.. - /// Range between from date and to date is not valid or has more than 100 days.. [HttpGet] [Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")] [ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppUsage)] [ApiCosts(0)] - public async Task GetUsages(string app, DateTime fromDate, DateTime toDate) + public async Task GetUsages(string app, DateOnly fromDate, DateOnly toDate) { - // We can only query 100 logs for up to 100 days. - if (fromDate > toDate && (toDate - fromDate).TotalDays > 100) - { - return BadRequest(); - } - - var (summary, details) = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted); + var (summary, details) = await usageTracker.QueryAsync(AppId.ToString(), fromDate, toDate, HttpContext.RequestAborted); // Use the current app plan to show the limits to the user. var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, false, HttpContext.RequestAborted); @@ -136,22 +129,15 @@ public sealed class UsagesController : ApiController /// The from date. /// The to date. /// API call returned.. - /// Range between from date and to date is not valid or has more than 100 days.. /// Team not found.. [HttpGet] [Route("teams/{team}/usages/calls/{fromDate}/{toDate}/")] [ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.TeamUsage)] [ApiCosts(0)] - public async Task GetUsagesForTeam(string team, DateTime fromDate, DateTime toDate) + public async Task GetUsagesForTeam(string team, DateOnly fromDate, DateOnly toDate) { - // We can only query 100 logs for up to 100 days. - if (fromDate > toDate && (toDate - fromDate).TotalDays > 100) - { - return BadRequest(); - } - - var (summary, details) = await usageTracker.QueryAsync(TeamId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted); + var (summary, details) = await usageTracker.QueryAsync(TeamId.ToString(), fromDate, toDate, HttpContext.RequestAborted); // Use the current team plan to show the limits to the user. var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); @@ -174,7 +160,7 @@ public sealed class UsagesController : ApiController [ApiCosts(0)] public async Task GetCurrentStorageSize(string app) { - var size = await assetStatsRepository.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted); + var (_, size) = await assetUsageTracker.GetTotalByAppAsync(AppId, HttpContext.RequestAborted); // Use the current app plan to show the limits to the user. var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, false, HttpContext.RequestAborted); @@ -197,7 +183,7 @@ public sealed class UsagesController : ApiController [ApiCosts(0)] public async Task GetTeamCurrentStorageSizeForTeam(string team) { - var size = await assetStatsRepository.GetTotalSizeByTeamAsync(TeamId, HttpContext.RequestAborted); + var (_, size) = await assetUsageTracker.GetTotalByTeamAsync(TeamId, HttpContext.RequestAborted); // Use the current team plan to show the limits to the user. var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); @@ -214,21 +200,15 @@ public sealed class UsagesController : ApiController /// The from date. /// The to date. /// Storage usage returned.. - /// Range between from date and to date is not valid or has more than 100 days.. /// App not found.. [HttpGet] [Route("apps/{app}/usages/storage/{fromDate}/{toDate}/")] [ProducesResponseType(typeof(StorageUsagePerDateDto[]), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppUsage)] [ApiCosts(0)] - public async Task GetStorageSizes(string app, DateTime fromDate, DateTime toDate) + public async Task GetStorageSizes(string app, DateOnly fromDate, DateOnly toDate) { - if (fromDate > toDate && (toDate - fromDate).TotalDays > 100) - { - return BadRequest(); - } - - var usages = await assetStatsRepository.QueryByAppAsync(AppId, fromDate.Date, toDate.Date, HttpContext.RequestAborted); + var usages = await assetUsageTracker.QueryByAppAsync(AppId, fromDate, toDate, HttpContext.RequestAborted); var models = usages.Select(StorageUsagePerDateDto.FromDomain).ToArray(); @@ -242,21 +222,15 @@ public sealed class UsagesController : ApiController /// The from date. /// The to date. /// Storage usage returned.. - /// Range between from date and to date is not valid or has more than 100 days.. /// Team not found.. [HttpGet] [Route("teams/{team}/usages/storage/{fromDate}/{toDate}/")] [ProducesResponseType(typeof(StorageUsagePerDateDto[]), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.TeamUsage)] [ApiCosts(0)] - public async Task GetStorageSizesForTeam(string team, DateTime fromDate, DateTime toDate) + public async Task GetStorageSizesForTeam(string team, DateOnly fromDate, DateOnly toDate) { - if (fromDate > toDate && (toDate - fromDate).TotalDays > 100) - { - return BadRequest(); - } - - var usages = await assetStatsRepository.QueryByTeamAsync(TeamId, fromDate.Date, toDate.Date, HttpContext.RequestAborted); + var usages = await assetUsageTracker.QueryByTeamAsync(TeamId, fromDate, toDate, HttpContext.RequestAborted); var models = usages.Select(StorageUsagePerDateDto.FromDomain).ToArray(); diff --git a/backend/src/Squidex/Config/Domain/SubscriptionServices.cs b/backend/src/Squidex/Config/Domain/SubscriptionServices.cs index cb76f6e01..dbc4c9db2 100644 --- a/backend/src/Squidex/Config/Domain/SubscriptionServices.cs +++ b/backend/src/Squidex/Config/Domain/SubscriptionServices.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Billing; +using Squidex.Domain.Apps.Entities.Rules; using Squidex.Infrastructure; using Squidex.Web; @@ -26,6 +27,6 @@ public static class SubscriptionServices .AsOptional(); services.AddSingletonAs() - .AsOptional().As(); + .AsOptional().As().As(); } } diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index d98684fc1..11ff05d17 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -71,7 +71,6 @@ - diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index 281dff641..386443375 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -247,7 +247,7 @@ // False to not use transactions. Improves performance. // // Warning: Can cause consistency issues. - "useTransactions": true, + "useTransactions": false, // The default page size if not specified by a query. // 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 33b27d89e..029230e02 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 @@ -144,9 +144,9 @@ public class RuleServiceTests [Fact] public void Should_not_run_from_snapshots_if_no_trigger_handler_registered() { - var context = RuleInvalidTrigger(); + var context = Rule(trigger: new InvalidTrigger()); - var actual = sut.CanCreateSnapshotEvents(context); + var actual = sut.CanCreateSnapshotEvents(context.Rule); Assert.False(actual); } @@ -159,7 +159,7 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(false); - var actual = sut.CanCreateSnapshotEvents(context); + var actual = sut.CanCreateSnapshotEvents(context.Rule); Assert.False(actual); } @@ -172,7 +172,7 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - var actual = sut.CanCreateSnapshotEvents(context); + var actual = sut.CanCreateSnapshotEvents(context.Rule); Assert.True(actual); } @@ -212,7 +212,7 @@ public class RuleServiceTests [Fact] public async Task Should_not_create_job_from_snapshots_if_no_trigger_handler_registered() { - var context = RuleInvalidTrigger(); + var context = Rule(trigger: new InvalidTrigger()); A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); @@ -228,7 +228,7 @@ public class RuleServiceTests [Fact] public async Task Should_not_create_job_from_snapshots_if_no_action_handler_registered() { - var context = RuleInvalidAction(); + var context = Rule(action: new InvalidAction()); A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); @@ -242,14 +242,14 @@ public class RuleServiceTests } [Fact] - public async Task Should_create_jobs_from_snapshots_if_rule_disabled_but_included() + public async Task Should_create_jobs_from_snapshots_if_rule_disabled_and_included() { var context = Rule(disable: true, includeSkipped: true); A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context.Rule.Trigger)) .Returns(true); A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default)) @@ -272,7 +272,7 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context.Rule.Trigger)) .Returns(true); A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default)) @@ -295,7 +295,7 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context.Rule.Trigger)) .Throws(new InvalidOperationException()); A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default)) @@ -319,11 +319,11 @@ public class RuleServiceTests var eventEnvelope = CreateEnvelope(new InvalidEvent()); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); Assert.Equal(SkipReason.WrongEvent, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } @@ -332,15 +332,15 @@ public class RuleServiceTests [InlineData(false)] public async Task Should_create_debug_job_if_no_trigger_handler_registered(bool includeSkipped) { - var context = RuleInvalidTrigger(includeSkipped); + var context = Rule(includeSkipped: includeSkipped, trigger: new InvalidTrigger()); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.NoTrigger, job.SkipReason); + Assert.Equal(SkipReason.NoTrigger, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } @@ -353,11 +353,11 @@ public class RuleServiceTests var eventEnvelope = CreateEnvelope(new ContentCreated()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.WrongEventForTrigger, job.SkipReason); + Assert.Equal(SkipReason.WrongEventForTrigger, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } @@ -366,18 +366,18 @@ public class RuleServiceTests [InlineData(false)] public async Task Should_create_debug_job_if_no_action_handler_registered(bool includeSkipped) { - var context = RuleInvalidAction(includeSkipped); + var context = Rule(includeSkipped: includeSkipped, action: new InvalidAction()); var eventEnvelope = CreateEnvelope(new ContentCreated()); A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.NoAction, job.SkipReason); + Assert.Equal(SkipReason.NoAction, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } @@ -388,23 +388,23 @@ public class RuleServiceTests var eventEnvelope = CreateEnvelope(new ContentCreated()); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); Assert.Equal(SkipReason.Disabled, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_create_job_if_rule_disabled_and_skipped_included() + public async Task Should_create_job_if_rule_disabled_and_included() { var context = Rule(disable: true, includeSkipped: true); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); AssertJob(eventEnriched, actual, SkipReason.Disabled); } @@ -421,11 +421,11 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.TooOld, job.SkipReason); + Assert.Equal(SkipReason.TooOld, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) .MustNotHaveHappened(); } @@ -437,24 +437,24 @@ public class RuleServiceTests var eventEnvelope = Envelope.Create(new ContentCreated()) .SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); AssertJob(eventEnriched, actual, SkipReason.None); } [Fact] - public async Task Should_create_job_if_too_old_but_skipped_are_included() + public async Task Should_create_job_if_too_old_and_included() { var context = Rule(includeSkipped: true); var eventEnvelope = Envelope.Create(new ContentCreated()) .SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); AssertJob(eventEnriched, actual, SkipReason.TooOld); } @@ -466,26 +466,26 @@ public class RuleServiceTests var eventEnvelope = CreateEnvelope(new ContentCreated { FromRule = true }); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.FromRule, job.SkipReason); + Assert.Equal(SkipReason.FromRule, actual.SkipReason); - A.CallTo(() => ruleTriggerHandler.Trigger(A>._, A._)) + A.CallTo(() => ruleTriggerHandler.Trigger(A>._, context.Rule.Trigger)) .MustNotHaveHappened(); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._, A._, A._)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._, A._, A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_job_if_event_created_by_rule_but_skipped_are_included() + public async Task Should_create_debug_job_if_event_created_by_rule_and_included() { var context = Rule(includeSkipped: true); var eventEnvelope = CreateEnvelope(new ContentCreated { FromRule = true }); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); AssertJob(eventEnriched, actual, SkipReason.FromRule); } @@ -501,31 +501,31 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context.Rule.Trigger)) .Returns(false); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context.ToRulesContext(), default)) .Returns(new List { eventEnriched }.ToAsyncEnumerable()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.ConditionPrecheckDoesNotMatch, job.SkipReason); - Assert.Null(job.EnrichedEvent); - Assert.Null(job.Job); + Assert.Equal(SkipReason.ConditionPrecheckDoesNotMatch, actual.SkipReason); + Assert.Null(actual.EnrichedEvent); + Assert.Null(actual.Job); } [Fact] - public async Task Should_create_job_if_not_triggered_with_precheck_but_skipped_are_included() + public async Task Should_create_job_if_not_triggered_with_precheck_and_included() { var context = Rule(includeSkipped: true); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context.Rule.Trigger)) .Returns(false); - var actual = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); AssertJob(eventEnriched, actual, SkipReason.ConditionPrecheckDoesNotMatch); } @@ -540,12 +540,12 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context.Rule.Trigger)) .Throws(new InvalidOperationException()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.Failed, job.SkipReason); + Assert.Equal(SkipReason.Failed, actual.SkipReason); } [Fact] @@ -558,19 +558,19 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context.Rule.Trigger)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context.ToRulesContext(), default)) .Returns(AsyncEnumerable.Empty()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).ToListAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).ToListAsync(); - Assert.Empty(job); + Assert.Empty(actual); } [Fact] - public async Task Should_create_debug_job_if_not_triggered() + public async Task Should_create_skipped_result_if_not_triggered_and_included() { var context = Rule(includeSkipped: true); @@ -580,39 +580,39 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context.Rule.Trigger)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context)) + A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context.Rule.Trigger)) .Returns(false); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context.ToRulesContext(), default)) .Returns(new List { eventEnriched }.ToAsyncEnumerable()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.ConditionDoesNotMatch, job.SkipReason); - Assert.Equal(eventEnriched, job.EnrichedEvent); + Assert.Equal(SkipReason.ConditionDoesNotMatch, actual.SkipReason); + Assert.Equal(eventEnriched, actual.EnrichedEvent); } [Fact] - public async Task Should_debug_job_if_not_triggered_but_skipped_included() + public async Task Should_create_debug_job_if_not_triggered_and_included() { var context = Rule(includeSkipped: true); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context)) + A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context.Rule.Trigger)) .Returns(false); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - AssertJob(eventEnriched, job, SkipReason.ConditionDoesNotMatch); + AssertJob(eventEnriched, actual, SkipReason.ConditionDoesNotMatch); } [Fact] - public async Task Should_create_debug_job_if_enrichment_failed() + public async Task Should_skipped_result_if_enrichment_failed() { var context = Rule(); @@ -621,15 +621,15 @@ public class RuleServiceTests A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context.Rule.Trigger)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context.ToRulesContext(), default)) .Throws(new InvalidOperationException()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.Equal(SkipReason.Failed, job.SkipReason); + Assert.Equal(SkipReason.Failed, actual.SkipReason); } [Fact] @@ -638,11 +638,11 @@ public class RuleServiceTests var context = Rule(); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - AssertJob(eventEnriched, job, SkipReason.None); + AssertJob(eventEnriched, actual, SkipReason.None); A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched, MatchPayload(eventEnvelope))) .MustHaveHappened(); @@ -654,17 +654,17 @@ public class RuleServiceTests var context = Rule(); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var eventEnriched = SetupFullFlow(context, eventEnvelope); + var eventEnriched = CreateDefaultFlow(context.Rule, eventEnvelope); A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched, context.Rule.Action)) .Throws(new InvalidOperationException()); - var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync(); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).SingleAsync(); - Assert.NotNull(job.EnrichmentError); - Assert.NotNull(job.Job?.ActionData); - Assert.NotNull(job.Job?.Description); - Assert.Equal(eventEnriched, job.EnrichedEvent); + Assert.NotNull(actual.EnrichmentError); + Assert.NotNull(actual.Job?.ActionData); + Assert.NotNull(actual.Job?.Description); + Assert.Equal(eventEnriched, actual.EnrichedEvent); A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched, MatchPayload(eventEnvelope))) .MustHaveHappened(); @@ -676,34 +676,16 @@ public class RuleServiceTests var context = Rule(); var eventEnvelope = CreateEnvelope(new ContentCreated()); - var eventEnriched1 = new EnrichedContentEvent { AppId = appId }; - var eventEnriched2 = new EnrichedContentEvent { AppId = appId }; - - A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) - .Returns(true); - - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) - .Returns(true); - - A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched1, context)) - .Returns(true); - - A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched2, context)) - .Returns(true); + var eventEnriched1 = CreateDefaultFlow(context.Rule, eventEnvelope); + var eventEnriched2 = CreateDefaultFlow(context.Rule, eventEnvelope); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), A._, default)) .Returns(new List { eventEnriched1, eventEnriched2 }.ToAsyncEnumerable()); - A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched1, context.Rule.Action)) - .Returns((actionDescription, new ValidData { Value = 10 })); + var actual = await sut.CreateJobsAsync(eventEnvelope, context.ToRulesContext()).ToListAsync(); - A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched2, context.Rule.Action)) - .Returns((actionDescription, new ValidData { Value = 10 })); - - var jobs = await sut.CreateJobsAsync(eventEnvelope, context, default).ToListAsync(); - - AssertJob(eventEnriched1, jobs[0], SkipReason.None); - AssertJob(eventEnriched2, jobs[1], SkipReason.None); + AssertJob(eventEnriched1, actual[0], SkipReason.None); + AssertJob(eventEnriched2, actual[1], SkipReason.None); A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched1, MatchPayload(eventEnvelope))) .MustHaveHappened(); @@ -713,7 +695,7 @@ public class RuleServiceTests } [Fact] - public async Task Should_return_succeeded_job_with_full_dump_if_handler_returns_no_exception() + public async Task Should_return_success_job_with_full_dump_if_handler_returns_no_exception() { A.CallTo(() => ruleActionHandler.ExecuteJobAsync(A.That.Matches(x => x.Value == 10), A._)) .Returns(Result.Success(actionDump)); @@ -741,7 +723,7 @@ public class RuleServiceTests } [Fact] - public async Task Should_return_timedout_job_with_full_dump_if_exception_from_handler_indicates_timeout() + public async Task Should_return_timeout_job_with_full_dump_if_exception_from_handler_indicates_timeout() { A.CallTo(() => ruleActionHandler.ExecuteJobAsync(A.That.Matches(x => x.Value == 10), A._)) .Returns(Result.Failed(new TimeoutException(), actionDump)); @@ -769,53 +751,31 @@ public class RuleServiceTests Assert.Equal(ex, actual.Result.Exception); } - private EnrichedContentEvent SetupFullFlow(RuleContext context, Envelope eventEnvelope) where T : AppEvent + private EnrichedContentEvent CreateDefaultFlow(Rule rule, Envelope eventEnvelope) where T : AppEvent { var eventEnriched = new EnrichedContentEvent { AppId = appId }; A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context)) + A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), rule.Trigger)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context)) + A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, rule.Trigger)) .Returns(true); - A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default)) + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), A._, default)) .Returns(new List { eventEnriched }.ToAsyncEnumerable()); - A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched, context.Rule.Action)) + A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched, rule.Action)) .Returns((actionDescription, new ValidData { Value = 10 })); return eventEnriched; } - private RuleContext RuleInvalidAction(bool includeSkipped = false) - { - return new RuleContext - { - AppId = appId, - Rule = new Rule(new ContentChangedTriggerV2(), new InvalidAction()), - RuleId = ruleId, - IncludeSkipped = includeSkipped - }; - } - - private RuleContext RuleInvalidTrigger(bool includeSkipped = false) - { - return new RuleContext - { - AppId = appId, - Rule = new Rule(new InvalidTrigger(), new ValidAction()), - RuleId = ruleId, - IncludeSkipped = includeSkipped - }; - } - - private RuleContext Rule(bool disable = false, bool includeStale = false, bool includeSkipped = false) + private RuleContext Rule(bool disable = false, bool includeStale = false, bool includeSkipped = false, RuleAction? action = null, RuleTrigger? trigger = null) { - var rule = new Rule(new ContentChangedTriggerV2(), new ValidAction()); + var rule = new Rule(trigger ?? new ContentChangedTriggerV2(), action ?? new ValidAction()); if (disable) { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs index ef1a339f0..d4751117b 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs @@ -64,26 +64,28 @@ public class JintScriptEngineTests : IClassFixture private sealed class AsyncExtension : IJintExtension { - private delegate void Delay(Action callback); + private delegate void Delay(Action callback, int time); public void ExtendAsync(ScriptExecutionContext context) { - context.Engine.SetValue("setTimeout", new Delay(callback => + context.Engine.SetValue("setTimeout", new Delay((callback, time) => { - context.Schedule(async (scheduler, ct) => + if (time == 0) { - await Task.Delay(1, ct); - scheduler.Run(callback); - }); - })); - - context.Engine.SetValue("setSyncTimeout", new Delay(callback => - { - context.Schedule((scheduler, ct) => + context.Schedule((scheduler, ct) => + { + scheduler.Run(callback); + return Task.CompletedTask; + }); + } + else { - scheduler.Run(callback); - return Task.CompletedTask; - }); + context.Schedule(async (scheduler, ct) => + { + await Task.Delay(time, ct); + scheduler.Run(callback); + }); + } })); } } @@ -626,57 +628,59 @@ public class JintScriptEngineTests : IClassFixture Assert.Equal(JsonValue.Create(28), actual["test"]!["iv"]); } - [Fact] - public async Task Should_not_run_callbacks_in_parallel() + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(10)] + [InlineData(100)] + public async Task Should_not_run_callbacks_in_parallel(int waitTime) { - for (var i = 0; i < 10; i++) + var vars = new DataScriptVars { - var vars = new DataScriptVars - { - ["value"] = 13 - }; + ["value"] = 13 + }; - const string script = @" - var x = ctx.value; - for (var i = 0; i < 100; i++) { - setTimeout(function () { - x++; - ctx.shared = x; - }); - } - "; + var script = @$" + var x = ctx.value; + for (var i = 0; i < 100; i++) {{ + setTimeout(function () {{ + x++; + ctx.shared = x; + }}, {waitTime}); + }} + "; - await sut.ExecuteAsync(vars, script, new ScriptOptions { AsContext = true }); + await sut.ExecuteAsync(vars, script, new ScriptOptions { AsContext = true }); - Assert.Equal(113.0, vars["shared"]); - } + Assert.Equal(113.0, vars["shared"]); } - [Fact] - public async Task Should_not_run_nested_callbacks_in_parallel() + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(10)] + [InlineData(100)] + public async Task Should_not_run_nested_callbacks_in_parallel(int waitTime) { - for (var i = 0; i < 10; i++) + var vars = new DataScriptVars { - var vars = new DataScriptVars - { - ["value"] = 13 - }; - - const string script = @" - var x = ctx.value; - for (var i = 0; i < 100; i++) { - setSyncTimeout(function () { - setSyncTimeout(function () { - x++; - ctx.shared = x; - }); - }); - } - "; + ["value"] = 13 + }; - await sut.ExecuteAsync(vars, script, new ScriptOptions { AsContext = true }); + var script = @$" + var x = ctx.value; + for (var i = 0; i < 100; i++) {{ + setTimeout(function () {{ + setTimeout(function () {{ + x++; + ctx.shared = x; + }}, {waitTime}); + }}, {waitTime}); + }} + "; - Assert.Equal(113.0, vars["shared"]); - } + await sut.ExecuteAsync(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(113.0, vars["shared"]); } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs index 3ccef6185..746f0e23c 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs @@ -29,41 +29,31 @@ public class SubscriptionPublisherTests [Fact] public void Should_return_content_and_asset_filter_for_events_filter() { - IEventConsumer consumer = sut; - - Assert.Equal("^(content-|asset-)", consumer.EventsFilter); + Assert.Equal("^(content-|asset-)", sut.EventsFilter); } [Fact] public async Task Should_do_nothing_on_clear() { - IEventConsumer consumer = sut; - - await consumer.ClearAsync(); + await ((IEventConsumer)sut).ClearAsync(); } [Fact] public void Should_return_custom_name_for_name() { - IEventConsumer consumer = sut; - - Assert.Equal("Subscriptions", consumer.Name); + Assert.Equal("Subscriptions", sut.Name); } [Fact] public void Should_not_support_clear() { - IEventConsumer consumer = sut; - - Assert.False(consumer.CanClear); + Assert.False(sut.CanClear); } [Fact] public void Should_start_from_latest() { - IEventConsumer consumer = sut; - - Assert.True(consumer.StartLatest); + Assert.True(sut.StartLatest); } [Theory] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppPermanentDeleterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppPermanentDeleterTests.cs index 8ce75a73f..41df184f0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppPermanentDeleterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppPermanentDeleterTests.cs @@ -29,25 +29,19 @@ public class AppPermanentDeleterTests : GivenContext [Fact] public void Should_return_assets_filter_for_events_filter() { - IEventConsumer consumer = sut; - - Assert.Equal("^app-", consumer.EventsFilter); + Assert.Equal("^app-", sut.EventsFilter); } [Fact] public async Task Should_do_nothing_on_clear() { - IEventConsumer consumer = sut; - - await consumer.ClearAsync(); + await ((IEventConsumer)sut).ClearAsync(); } [Fact] public void Should_return_type_name_for_name() { - IEventConsumer consumer = sut; - - Assert.Equal(nameof(AppPermanentDeleter), consumer.Name); + Assert.Equal(nameof(AppPermanentDeleter), sut.Name); } [Fact] 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 b492f8cd0..763a4d59a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs @@ -76,7 +76,7 @@ public class AssetChangedTriggerHandlerTests : GivenContext { var ctx = Context(); - A.CallTo(() => assetRepository.StreamAll(ctx.AppId.Id, CancellationToken)) + A.CallTo(() => assetRepository.StreamAll(AppId.Id, CancellationToken)) .Returns(new List { new AssetEntity(), @@ -95,11 +95,11 @@ public class AssetChangedTriggerHandlerTests : GivenContext [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type) { - var ctx = Context(appId: @event.AppId); + var ctx = Context().ToRulesContext(); var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - A.CallTo(() => assetLoader.GetAsync(ctx.AppId.Id, @event.AssetId, 12, CancellationToken)) + A.CallTo(() => assetLoader.GetAsync(AppId.Id, @event.AssetId, 12, CancellationToken)) .Returns(new AssetEntity()); var actual = await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); @@ -119,7 +119,7 @@ public class AssetChangedTriggerHandlerTests : GivenContext { var @event = new EnrichedAssetEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -132,7 +132,7 @@ public class AssetChangedTriggerHandlerTests : GivenContext { var @event = new EnrichedAssetEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -145,7 +145,7 @@ public class AssetChangedTriggerHandlerTests : GivenContext { var @event = new EnrichedAssetEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); @@ -172,13 +172,13 @@ public class AssetChangedTriggerHandlerTests : GivenContext } } - private static RuleContext Context(RuleTrigger? trigger = null, NamedId? appId = null) + private RuleContext Context(RuleTrigger? trigger = null) { trigger ??= new AssetChangedTriggerV2(); return new RuleContext { - AppId = appId ?? NamedId.Of(DomainId.NewGuid(), "my-app"), + AppId = AppId, Rule = new Rule(trigger, A.Fake()), RuleId = DomainId.NewGuid() }; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetPermanentDeleterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetPermanentDeleterTests.cs index bb9ae8054..9ada40d63 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetPermanentDeleterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetPermanentDeleterTests.cs @@ -27,25 +27,19 @@ public class AssetPermanentDeleterTests : GivenContext [Fact] public void Should_return_assets_filter_for_events_filter() { - IEventConsumer consumer = sut; - - Assert.Equal("^asset-", consumer.EventsFilter); + Assert.Equal("^asset-", sut.EventsFilter); } [Fact] public async Task Should_do_nothing_on_clear() { - IEventConsumer consumer = sut; - - await consumer.ClearAsync(); + await ((IEventConsumer)sut).ClearAsync(); } [Fact] public void Should_return_type_name_for_name() { - IEventConsumer consumer = sut; - - Assert.Equal(nameof(AssetPermanentDeleter), consumer.Name); + Assert.Equal(nameof(AssetPermanentDeleter), sut.Name); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs index 9bde2831d..4d6d5b48f 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs @@ -7,7 +7,6 @@ using NodaTime; using Squidex.Domain.Apps.Core.Tags; -using Squidex.Domain.Apps.Entities.Billing; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; @@ -19,9 +18,9 @@ namespace Squidex.Domain.Apps.Entities.Assets; public class AssetUsageTrackerTests : GivenContext { private readonly IAssetLoader assetLoader = A.Fake(); + private readonly IAssetUsageTracker assetUsageTracker = A.Fake(); private readonly ISnapshotStore store = A.Fake>(); private readonly ITagService tagService = A.Fake(); - private readonly IUsageGate usageGate = A.Fake(); private readonly DomainId assetId = DomainId.NewGuid(); private readonly DomainId assetKey; private readonly AssetUsageTracker sut; @@ -30,31 +29,31 @@ public class AssetUsageTrackerTests : GivenContext { assetKey = DomainId.Combine(AppId, assetId); - sut = new AssetUsageTracker(usageGate, assetLoader, tagService, store); + sut = new AssetUsageTracker(assetLoader, assetUsageTracker, tagService, store); } [Fact] public void Should_return_assets_filter_for_events_filter() { - IEventConsumer consumer = sut; - - Assert.Equal("^asset-", consumer.EventsFilter); + Assert.Equal("^asset-", sut.EventsFilter); } [Fact] public async Task Should_do_nothing_on_clear() { - IEventConsumer consumer = sut; - - await consumer.ClearAsync(); + await sut.ClearAsync(); } [Fact] public void Should_return_type_name_for_name() { - IEventConsumer consumer = sut; + Assert.Equal(nameof(AssetUsageTracker), sut.Name); + } - Assert.Equal(nameof(AssetUsageTracker), consumer.Name); + [Fact] + public void Should_process_in_batches() + { + Assert.True(sut.BatchSize > 1); } public static IEnumerable EventData() @@ -79,17 +78,17 @@ public class AssetUsageTrackerTests : GivenContext [MemberData(nameof(EventData))] public async Task Should_increase_usage_if_for_event(AssetEvent @event, long sizeDiff, long countDiff) { - var date = DateTime.UtcNow.Date.AddDays(13); + var date = DateTime.UtcNow.Date.AddDays(13).ToDateOnly(); @event.AppId = AppId; var envelope = Envelope.Create(@event) - .SetTimestamp(Instant.FromDateTimeUtc(date)); + .SetTimestamp(Instant.FromDateTimeUtc(DateTime.UtcNow.Date.AddDays(13))); await sut.On(new[] { envelope }); - A.CallTo(() => usageGate.TrackAssetAsync(AppId.Id, date, sizeDiff, countDiff, default)) + A.CallTo(() => assetUsageTracker.TrackAsync(AppId.Id, date, sizeDiff, countDiff, default)) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs index bfa4c048e..ffbd44f1a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs @@ -33,25 +33,19 @@ public class RecursiveDeleterTests : GivenContext [Fact] public void Should_return_assets_filter_for_events_filter() { - IEventConsumer consumer = sut; - - Assert.Equal("^assetFolder-", consumer.EventsFilter); + Assert.Equal("^assetFolder-", sut.EventsFilter); } [Fact] public async Task Should_do_nothing_on_clear() { - IEventConsumer consumer = sut; - - await consumer.ClearAsync(); + await ((IEventConsumer)sut).ClearAsync(); } [Fact] public void Should_return_type_name_for_name() { - IEventConsumer consumer = sut; - - Assert.Equal(nameof(RecursiveDeleter), consumer.Name); + Assert.Equal(nameof(RecursiveDeleter), sut.Name); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageGateTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageGateTests.cs index 357d12cc8..5862284ed 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageGateTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageGateTests.cs @@ -8,7 +8,7 @@ using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Teams; +using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.UsageTracking; @@ -23,8 +23,7 @@ public class UsageGateTests : GivenContext private readonly IBillingPlans billingPlans = A.Fake(); private readonly IUsageTracker usageTracker = A.Fake(); private readonly string clientId = Guid.NewGuid().ToString(); - private readonly DomainId teamId = DomainId.NewGuid(); - private readonly DateTime today = new DateTime(2020, 10, 3); + private readonly DateOnly today = new DateOnly(2020, 10, 3); private readonly Plan planFree = new Plan { Id = "free" }; private readonly Plan planPaid = new Plan { Id = "paid" }; private readonly UsageGate sut; @@ -44,9 +43,9 @@ public class UsageGateTests : GivenContext } [Fact] - public async Task Should_delete_app_asset_usage() + public async Task Should_delete_assets_usage_by_app() { - await sut.DeleteAssetUsageAsync(AppId.Id, CancellationToken); + await ((IAssetUsageTracker)sut).DeleteUsageAsync(AppId.Id, CancellationToken); A.CallTo(() => usageTracker.DeleteAsync($"{AppId.Id}_Assets", CancellationToken)) .MustHaveHappened(); @@ -55,9 +54,18 @@ public class UsageGateTests : GivenContext [Fact] public async Task Should_delete_assets_usage() { - await sut.DeleteAssetsUsageAsync(CancellationToken); + await ((IAssetUsageTracker)sut).DeleteUsageAsync(CancellationToken); - A.CallTo(() => usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_Assets", CancellationToken)) + A.CallTo(() => usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_[A-Za-z]+Assets", CancellationToken)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_delete_rules_usage_by_app() + { + await ((IRuleUsageTracker)sut).DeleteUsageAsync(AppId.Id, CancellationToken); + + A.CallTo(() => usageTracker.DeleteAsync($"{AppId.Id}_Rules", CancellationToken)) .MustHaveHappened(); } @@ -72,20 +80,12 @@ public class UsageGateTests : GivenContext [Fact] public async Task Should_get_free_plan_for_app_with_team() { - var team = A.Fake(); - - A.CallTo(() => AppProvider.GetTeamAsync(teamId, CancellationToken)) - .Returns(team); - - A.CallTo(() => team.Id) - .Returns(teamId); - A.CallTo(() => App.TeamId) - .Returns(teamId); + .Returns(TeamId); var plan = await sut.GetPlanForAppAsync(App, false, CancellationToken); - Assert.Equal((planFree, planFree.Id, teamId), plan); + Assert.Equal((planFree, planFree.Id, TeamId), plan); } [Fact] @@ -113,23 +113,15 @@ public class UsageGateTests : GivenContext [Fact] public async Task Should_get_paid_plan_for_app_with_team() { - var team = A.Fake(); - - A.CallTo(() => AppProvider.GetTeamAsync(teamId, CancellationToken)) - .Returns(team); - - A.CallTo(() => team.Id) - .Returns(teamId); - - A.CallTo(() => team.Plan) + A.CallTo(() => Team.Plan) .Returns(new AssignedPlan(RefToken.User("1"), planPaid.Id)); A.CallTo(() => App.TeamId) - .Returns(teamId); + .Returns(TeamId); var plan = await sut.GetPlanForAppAsync(App, false, CancellationToken); - Assert.Equal((planPaid, planPaid.Id, teamId), plan); + Assert.Equal((planPaid, planPaid.Id, TeamId), plan); } [Fact] @@ -251,7 +243,7 @@ public class UsageGateTests : GivenContext [Fact] public async Task Should_not_notify_if_lower_than_10_percent() { - var now = new DateTime(2020, 10, 2); + var now = new DateOnly(2020, 10, 2); var plan = new Plan { Id = "custom", BlockingApiCalls = 5000, MaxApiCalls = 3000 }; @@ -269,106 +261,179 @@ public class UsageGateTests : GivenContext } [Fact] - public async Task Should_get_app_asset_total_size_from_summary_date() + public async Task Should_track_api_request_without_team() { - A.CallTo(() => usageTracker.GetAsync($"{AppId.Id}_Assets", default, default, null, CancellationToken)) - .Returns(new Counters { ["TotalSize"] = 2048 }); - - var size = await sut.GetTotalSizeByAppAsync(AppId.Id, CancellationToken); + await sut.TrackRequestAsync(App, "client", today, 42, 50, 512, CancellationToken); - Assert.Equal(2048, size); + A.CallTo(() => apiUsageTracker.TrackAsync(today, AppId.Id.ToString(), "client", 42, 50, 512, CancellationToken)) + .MustHaveHappened(); } [Fact] - public async Task Should_get_team_asset_total_size_from_summary_date() + public async Task Should_track_api_request_with_team() { - A.CallTo(() => usageTracker.GetAsync($"{AppId.Id}_TeamAssets", default, default, null, CancellationToken)) - .Returns(new Counters { ["TotalSize"] = 2048 }); + A.CallTo(() => App.TeamId) + .Returns(TeamId); - var size = await sut.GetTotalSizeByTeamAsync(AppId.Id, CancellationToken); + await sut.TrackRequestAsync(App, "client", today, 42, 50, 512, CancellationToken); - Assert.Equal(2048, size); + A.CallTo(() => apiUsageTracker.TrackAsync(today, App.TeamId!.ToString()!, AppId.Name, 42, 50, 512, CancellationToken)) + .MustHaveHappened(); } [Fact] - public async Task Should_track_request_async() + public async Task Should_track_rules_usage_without_team() { - await sut.TrackRequestAsync(App, "client", today, 42, 50, 512, CancellationToken); + Counters? countersSummary = null; + Counters? countersDate = null; - A.CallTo(() => apiUsageTracker.TrackAsync(today, AppId.Id.ToString(), "client", 42, 50, 512, CancellationToken)) - .MustHaveHappened(); + var ruleId = DomainId.NewGuid(); + + A.CallTo(() => usageTracker.TrackAsync(default, $"{AppId.Id}_Rules", ruleId.ToString(), A._, CancellationToken)) + .Invokes(x => countersSummary = x.GetArgument(3)); + + A.CallTo(() => usageTracker.TrackAsync(today, $"{AppId.Id}_Rules", ruleId.ToString(), A._, CancellationToken)) + .Invokes(x => countersDate = x.GetArgument(3)); + + await ((IRuleUsageTracker)sut).TrackAsync(AppId.Id, ruleId, today, 100, 120, 140, CancellationToken); + + var expected = new Counters + { + [UsageGate.RulesKeys.TotalCreated] = 100, + [UsageGate.RulesKeys.TotalSucceeded] = 120, + [UsageGate.RulesKeys.TotalFailed] = 140 + }; + + countersSummary.Should().BeEquivalentTo(expected); + countersDate.Should().BeEquivalentTo(expected); } [Fact] - public async Task Should_track_request_for_team_async() + public async Task Should_track_rules_usage_with_team() { + Counters? countersSummary = null; + Counters? countersDate = null; + + var ruleId = DomainId.NewGuid(); + A.CallTo(() => App.TeamId) - .Returns(teamId); + .Returns(TeamId); - await sut.TrackRequestAsync(App, "client", today, 42, 50, 512, CancellationToken); + A.CallTo(() => usageTracker.TrackAsync(default, $"{TeamId}_TeamRules", AppId.Id.ToString(), A._, CancellationToken)) + .Invokes(x => countersSummary = x.GetArgument(3)); - A.CallTo(() => apiUsageTracker.TrackAsync(today, App.TeamId!.ToString()!, AppId.Name, 42, 50, 512, CancellationToken)) - .MustHaveHappened(); + A.CallTo(() => usageTracker.TrackAsync(today, $"{TeamId}_TeamRules", AppId.Id.ToString(), A._, CancellationToken)) + .Invokes(x => countersDate = x.GetArgument(3)); + + await ((IRuleUsageTracker)sut).TrackAsync(AppId.Id, ruleId, today, 100, 120, 140, CancellationToken); + + var expected = new Counters + { + [UsageGate.RulesKeys.TotalCreated] = 100, + [UsageGate.RulesKeys.TotalSucceeded] = 120, + [UsageGate.RulesKeys.TotalFailed] = 140 + }; + + countersSummary.Should().BeEquivalentTo(expected); + countersDate.Should().BeEquivalentTo(expected); } [Fact] - public async Task Should_get_app_asset_counters_from_categories() + public async Task Should_get_rules_total_from_summary_date_by_app() { - SetupAssetQuery($"{AppId.Id}_Assets"); + A.CallTo(() => usageTracker.QueryAsync($"{AppId.Id}_Rules", default, default, CancellationToken)) + .Returns( + new Dictionary> + { + [AppId.Id.ToString()] = new List<(DateOnly, Counters)> + { + (default, new Counters + { + [UsageGate.RulesKeys.TotalCreated] = 100, + [UsageGate.RulesKeys.TotalSucceeded] = 120, + [UsageGate.RulesKeys.TotalFailed] = 140 + }) + } + }); + + var total = await ((IRuleUsageTracker)sut).GetTotalByAppAsync(AppId.Id, CancellationToken); + + total.Should().BeEquivalentTo(new Dictionary + { + [AppId.Id] = new RuleCounters(100, 120, 140) + }); + } + + [Fact] + public async Task Should_query_rules_counters_by_app() + { + SetupRulesQuery($"{AppId.Id}_Rules"); - var actual = await sut.QueryByAppAsync(AppId.Id, today, today.AddDays(3), CancellationToken); + var actual = await ((IRuleUsageTracker)sut).QueryByAppAsync(AppId.Id, today, today.AddDays(2), CancellationToken); - actual.Should().BeEquivalentTo(new List + actual.Should().BeEquivalentTo(new List { - new AssetStats(today.AddDays(0), 2, 128), - new AssetStats(today.AddDays(1), 3, 256), - new AssetStats(today.AddDays(2), 4, 512) + new RuleStats(today.AddDays(0), new RuleCounters(100, 120, 140)), + new RuleStats(today.AddDays(1), new RuleCounters(200, 220, 240)), + new RuleStats(today.AddDays(2), new RuleCounters(300, 320, 340)) }); } [Fact] - public async Task Should_get_team_asset_counters_from_categories() + public async Task Should_query_rules_countery_by_team() { - SetupAssetQuery($"{AppId.Id}_TeamAssets"); + SetupRulesQuery($"{TeamId}_TeamRules"); - var actual = await sut.QueryByTeamAsync(AppId.Id, today, today.AddDays(3), CancellationToken); + var actual = await ((IRuleUsageTracker)sut).QueryByTeamAsync(TeamId, today, today.AddDays(2), CancellationToken); - actual.Should().BeEquivalentTo(new List + actual.Should().BeEquivalentTo(new List { - new AssetStats(today.AddDays(0), 2, 128), - new AssetStats(today.AddDays(1), 3, 256), - new AssetStats(today.AddDays(2), 4, 512) + new RuleStats(today.AddDays(0), new RuleCounters(100, 120, 140)), + new RuleStats(today.AddDays(1), new RuleCounters(200, 220, 240)), + new RuleStats(today.AddDays(2), new RuleCounters(300, 320, 340)) }); } - private void SetupAssetQuery(string key) + private void SetupRulesQuery(string key) { - A.CallTo(() => usageTracker.QueryAsync(key, today, today.AddDays(3), CancellationToken)) - .Returns(new Dictionary> + A.CallTo(() => usageTracker.QueryAsync(key, today, today.AddDays(2), CancellationToken)) + .Returns(new Dictionary> { - [usageTracker.FallbackCategory] = new List<(DateTime, Counters)> + [usageTracker.FallbackCategory] = new List<(DateOnly, Counters)> { (today.AddDays(0), new Counters { - ["TotalSize"] = 128, - ["TotalAssets"] = 2 + [UsageGate.RulesKeys.TotalCreated] = 50, + [UsageGate.RulesKeys.TotalSucceeded] = 60, + [UsageGate.RulesKeys.TotalFailed] = 70 }), (today.AddDays(1), new Counters { - ["TotalSize"] = 256, - ["TotalAssets"] = 3 + [UsageGate.RulesKeys.TotalCreated] = 200, + [UsageGate.RulesKeys.TotalSucceeded] = 220, + [UsageGate.RulesKeys.TotalFailed] = 240 }), (today.AddDays(2), new Counters { - ["TotalSize"] = 512, - ["TotalAssets"] = 4 + [UsageGate.RulesKeys.TotalCreated] = 300, + [UsageGate.RulesKeys.TotalSucceeded] = 320, + [UsageGate.RulesKeys.TotalFailed] = 340 + }) + }, + ["Custom"] = new List<(DateOnly, Counters)> + { + (today.AddDays(0), new Counters + { + [UsageGate.RulesKeys.TotalCreated] = 50, + [UsageGate.RulesKeys.TotalSucceeded] = 60, + [UsageGate.RulesKeys.TotalFailed] = 70 }) } }); } [Fact] - public async Task Should_increase_usage_for_asset_event() + public async Task Should_track_assets_usage_without_team() { Counters? countersSummary = null; Counters? countersDate = null; @@ -379,12 +444,12 @@ public class UsageGateTests : GivenContext A.CallTo(() => usageTracker.TrackAsync(today, $"{AppId.Id}_Assets", null, A._, CancellationToken)) .Invokes(x => countersDate = x.GetArgument(3)); - await sut.TrackAssetAsync(AppId.Id, today, 512, 3, CancellationToken); + await ((IAssetUsageTracker)sut).TrackAsync(AppId.Id, today, 512, 3, CancellationToken); var expected = new Counters { - ["TotalSize"] = 512, - ["TotalAssets"] = 3 + [UsageGate.AssetsKeys.TotalSize] = 512, + [UsageGate.AssetsKeys.TotalAssets] = 3 }; countersSummary.Should().BeEquivalentTo(expected); @@ -392,37 +457,123 @@ public class UsageGateTests : GivenContext } [Fact] - public async Task Should_increase_team_usage_for_asset_event_and_team_app() + public async Task Should_track_assets_usage_with_team() { Counters? countersSummary = null; Counters? countersDate = null; - var team = A.Fake(); - - A.CallTo(() => team.Id) - .Returns(teamId); - A.CallTo(() => App.TeamId) - .Returns(teamId); + .Returns(TeamId); - A.CallTo(() => AppProvider.GetTeamAsync(teamId, CancellationToken)) - .Returns(team); - - A.CallTo(() => usageTracker.TrackAsync(default, $"{teamId}_TeamAssets", null, A._, CancellationToken)) + A.CallTo(() => usageTracker.TrackAsync(default, $"{TeamId}_TeamAssets", AppId.Id.ToString(), A._, CancellationToken)) .Invokes(x => countersSummary = x.GetArgument(3)); - A.CallTo(() => usageTracker.TrackAsync(today, $"{teamId}_TeamAssets", null, A._, CancellationToken)) + A.CallTo(() => usageTracker.TrackAsync(today, $"{TeamId}_TeamAssets", AppId.Id.ToString(), A._, CancellationToken)) .Invokes(x => countersDate = x.GetArgument(3)); - await sut.TrackAssetAsync(AppId.Id, today, 512, 3, CancellationToken); + await ((IAssetUsageTracker)sut).TrackAsync(AppId.Id, today, 512, 3, CancellationToken); var expected = new Counters { - ["TotalSize"] = 512, - ["TotalAssets"] = 3 + [UsageGate.AssetsKeys.TotalSize] = 512, + [UsageGate.AssetsKeys.TotalAssets] = 3 }; countersSummary.Should().BeEquivalentTo(expected); countersDate.Should().BeEquivalentTo(expected); } + + [Fact] + public async Task Should_get_assets_total_from_summary_date_by_app() + { + A.CallTo(() => usageTracker.GetAsync($"{AppId.Id}_Assets", default, default, null, CancellationToken)) + .Returns(new Counters + { + [UsageGate.AssetsKeys.TotalSize] = 2048, + [UsageGate.AssetsKeys.TotalAssets] = 124 + }); + + var total = await ((IAssetUsageTracker)sut).GetTotalByAppAsync(AppId.Id, CancellationToken); + + Assert.Equal(new AssetCounters(2048, 124), total); + } + + [Fact] + public async Task Should_get_assets_total_from_summary_date_by_team() + { + A.CallTo(() => usageTracker.GetAsync($"{AppId.Id}_TeamAssets", default, default, null, CancellationToken)) + .Returns(new Counters + { + [UsageGate.AssetsKeys.TotalSize] = 2048, + [UsageGate.AssetsKeys.TotalAssets] = 124 + }); + + var total = await ((IAssetUsageTracker)sut).GetTotalByTeamAsync(AppId.Id, CancellationToken); + + Assert.Equal(new AssetCounters(2048, 124), total); + } + + [Fact] + public async Task Should_query_assets_counters_by_app() + { + SetupAssetQuery($"{AppId.Id}_Assets"); + + var actual = await ((IAssetUsageTracker)sut).QueryByAppAsync(AppId.Id, today, today.AddDays(2), CancellationToken); + + actual.Should().BeEquivalentTo(new List + { + new AssetStats(today.AddDays(0), new AssetCounters(128, 2)), + new AssetStats(today.AddDays(1), new AssetCounters(256, 3)), + new AssetStats(today.AddDays(2), new AssetCounters(512, 4)) + }); + } + + [Fact] + public async Task Should_query_assets_countery_by_team() + { + SetupAssetQuery($"{TeamId}_TeamAssets"); + + var actual = await ((IAssetUsageTracker)sut).QueryByTeamAsync(TeamId, today, today.AddDays(2), CancellationToken); + + actual.Should().BeEquivalentTo(new List + { + new AssetStats(today.AddDays(0), new AssetCounters(128, 2)), + new AssetStats(today.AddDays(1), new AssetCounters(256, 3)), + new AssetStats(today.AddDays(2), new AssetCounters(512, 4)) + }); + } + + private void SetupAssetQuery(string key) + { + A.CallTo(() => usageTracker.QueryAsync(key, today, today.AddDays(2), CancellationToken)) + .Returns(new Dictionary> + { + [usageTracker.FallbackCategory] = new List<(DateOnly, Counters)> + { + (today.AddDays(0), new Counters + { + [UsageGate.AssetsKeys.TotalSize] = 64, + [UsageGate.AssetsKeys.TotalAssets] = 1 + }), + (today.AddDays(1), new Counters + { + [UsageGate.AssetsKeys.TotalSize] = 256, + [UsageGate.AssetsKeys.TotalAssets] = 3 + }), + (today.AddDays(2), new Counters + { + [UsageGate.AssetsKeys.TotalSize] = 512, + [UsageGate.AssetsKeys.TotalAssets] = 4 + }) + }, + ["Custom"] = new List<(DateOnly, Counters)> + { + (today.AddDays(0), new Counters + { + [UsageGate.AssetsKeys.TotalSize] = 64, + [UsageGate.AssetsKeys.TotalAssets] = 1 + }) + } + }); + } } 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 389912a70..db3057d0b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs @@ -66,7 +66,7 @@ public class CommentTriggerHandlerTests [Fact] public async Task Should_create_enriched_events() { - var ctx = Context(); + var ctx = Context().ToRulesContext(); var user1 = UserMocks.User("1"); var user2 = UserMocks.User("2"); @@ -96,7 +96,7 @@ public class CommentTriggerHandlerTests [Fact] public async Task Should_not_create_enriched_events_if_users_cannot_be_resolved() { - var ctx = Context(); + var ctx = Context().ToRulesContext(); var user1 = UserMocks.User("1"); var user2 = UserMocks.User("2"); @@ -115,7 +115,7 @@ public class CommentTriggerHandlerTests [Fact] public async Task Should_not_create_enriched_events_if_mentions_is_null() { - var ctx = Context(); + var ctx = Context().ToRulesContext(); var @event = new CommentCreated { Mentions = null }; var envelope = Envelope.Create(@event); @@ -131,7 +131,7 @@ public class CommentTriggerHandlerTests [Fact] public async Task Should_not_create_enriched_events_if_mentions_is_empty() { - var ctx = Context(); + var ctx = Context().ToRulesContext(); var @event = new CommentCreated { Mentions = Array.Empty() }; var envelope = Envelope.Create(@event); @@ -151,7 +151,7 @@ public class CommentTriggerHandlerTests { var @event = new CommentCreated(); - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.True(actual); }); @@ -164,7 +164,7 @@ public class CommentTriggerHandlerTests { var @event = new EnrichedCommentEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -177,7 +177,7 @@ public class CommentTriggerHandlerTests { var @event = new EnrichedCommentEvent(); - var actual = sut.Trigger(new EnrichedCommentEvent(), ctx); + var actual = sut.Trigger(new EnrichedCommentEvent(), ctx.Rule.Trigger); Assert.True(actual); }); @@ -190,7 +190,7 @@ public class CommentTriggerHandlerTests { var @event = new EnrichedCommentEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); @@ -206,7 +206,7 @@ public class CommentTriggerHandlerTests MentionedUser = UserMocks.User("1", "1@email.com") }; - var actual = handler.Trigger(@event, ctx); + var actual = handler.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -222,7 +222,7 @@ public class CommentTriggerHandlerTests MentionedUser = UserMocks.User("1", "1@email.com") }; - var actual = handler.Trigger(@event, ctx); + var actual = handler.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); @@ -238,7 +238,7 @@ public class CommentTriggerHandlerTests Text = "very_urgent_text" }; - var actual = handler.Trigger(@event, ctx); + var actual = handler.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -254,7 +254,7 @@ public class CommentTriggerHandlerTests Text = "just_gossip" }; - var actual = handler.Trigger(@event, ctx); + var actual = handler.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); 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 28ba4768c..6ab5686db 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -29,8 +29,8 @@ public class ContentChangedTriggerHandlerTests : GivenContext private readonly IScriptEngine scriptEngine = A.Fake(); private readonly IContentLoader contentLoader = A.Fake(); private readonly IContentRepository contentRepository = A.Fake(); - private readonly NamedId schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1"); - private readonly NamedId schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2"); + private readonly NamedId schemaMatching = NamedId.Of(DomainId.NewGuid(), "my-schema1"); + private readonly NamedId schemaNotMatching = NamedId.Of(DomainId.NewGuid(), "my-schema2"); private readonly IRuleTriggerHandler sut; public ContentChangedTriggerHandlerTests() @@ -75,7 +75,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_calculate_name_for_created() { - var @event = new ContentCreated { SchemaId = schemaMatch }; + var @event = new ContentCreated { SchemaId = schemaMatching }; Assert.Equal("MySchema1Created", sut.GetName(@event)); } @@ -83,7 +83,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_calculate_name_for_deleted() { - var @event = new ContentDeleted { SchemaId = schemaMatch }; + var @event = new ContentDeleted { SchemaId = schemaMatching }; Assert.Equal("MySchema1Deleted", sut.GetName(@event)); } @@ -91,7 +91,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_calculate_name_for_updated() { - var @event = new ContentUpdated { SchemaId = schemaMatch }; + var @event = new ContentUpdated { SchemaId = schemaMatching }; Assert.Equal("MySchema1Updated", sut.GetName(@event)); } @@ -99,7 +99,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_calculate_name_for_published() { - var @event = new ContentStatusChanged { SchemaId = schemaMatch, Change = StatusChange.Published }; + var @event = new ContentStatusChanged { SchemaId = schemaMatching, Change = StatusChange.Published }; Assert.Equal("MySchema1Published", sut.GetName(@event)); } @@ -107,7 +107,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_calculate_name_for_unpublished() { - var @event = new ContentStatusChanged { SchemaId = schemaMatch, Change = StatusChange.Unpublished }; + var @event = new ContentStatusChanged { SchemaId = schemaMatching, Change = StatusChange.Unpublished }; Assert.Equal("MySchema1Unpublished", sut.GetName(@event)); } @@ -115,7 +115,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_calculate_name_for_status_change() { - var @event = new ContentStatusChanged { SchemaId = schemaMatch, Change = StatusChange.Change }; + var @event = new ContentStatusChanged { SchemaId = schemaMatching, Change = StatusChange.Change }; Assert.Equal("MySchema1StatusChanged", sut.GetName(@event)); } @@ -125,11 +125,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext { var ctx = Context(); - A.CallTo(() => contentRepository.StreamAll(ctx.AppId.Id, null, CancellationToken)) + A.CallTo(() => contentRepository.StreamAll(AppId.Id, null, CancellationToken)) .Returns(new List { - new ContentEntity { SchemaId = schemaMatch }, - new ContentEntity { SchemaId = schemaNonMatch } + new ContentEntity { SchemaId = schemaMatching }, + new ContentEntity { SchemaId = schemaNotMatching } }.ToAsyncEnumerable()); var actual = await sut.CreateSnapshotEventsAsync(ctx, CancellationToken).ToListAsync(CancellationToken); @@ -149,19 +149,19 @@ public class ContentChangedTriggerHandlerTests : GivenContext var trigger = new ContentChangedTriggerV2 { Schemas = ReadonlyList.Create( - new ContentChangedTriggerSchemaV2 + new SchemaCondition { - SchemaId = schemaMatch.Id + SchemaId = schemaMatching.Id }) }; var ctx = Context(trigger); - A.CallTo(() => contentRepository.StreamAll(ctx.AppId.Id, A>.That.Is(schemaMatch.Id), CancellationToken)) + A.CallTo(() => contentRepository.StreamAll(AppId.Id, A>.That.Is(schemaMatching.Id), CancellationToken)) .Returns(new List { - new ContentEntity { SchemaId = schemaMatch }, - new ContentEntity { SchemaId = schemaMatch } + new ContentEntity { SchemaId = schemaMatching }, + new ContentEntity { SchemaId = schemaMatching } }.ToAsyncEnumerable()); var actual = await sut.CreateSnapshotEventsAsync(ctx, CancellationToken).ToListAsync(CancellationToken); @@ -176,52 +176,151 @@ public class ContentChangedTriggerHandlerTests : GivenContext [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) { - var ctx = Context(); + var ctx = Context().ToRulesContext(); - @event.AppId = ctx.AppId; - @event.SchemaId = schemaMatch; + @event.AppId = AppId; + @event.SchemaId = schemaMatching; var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 12, CancellationToken)) + A.CallTo(() => contentLoader.GetAsync(AppId.Id, @event.ContentId, 12, CancellationToken)) .Returns(SimpleMapper.Map(@event, new ContentEntity())); - var actual = await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); - - var enrichedEvent = (EnrichedContentEvent)actual.Single(); + var actuals = await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + var actual = (EnrichedContentEvent)actuals.Single(); - Assert.Equal(type, enrichedEvent!.Type); - Assert.Equal(@event.Actor, enrichedEvent.Actor); - Assert.Equal(@event.AppId, enrichedEvent.AppId); - Assert.Equal(@event.AppId.Id, enrichedEvent.AppId.Id); - Assert.Equal(@event.SchemaId, enrichedEvent.SchemaId); - Assert.Equal(@event.SchemaId.Id, enrichedEvent.SchemaId.Id); + Assert.Equal(type, actual!.Type); + Assert.Equal(@event.Actor, actual.Actor); + Assert.Equal(@event.AppId, actual.AppId); + Assert.Equal(@event.AppId.Id, actual.AppId.Id); + Assert.Equal(@event.SchemaId, actual.SchemaId); + Assert.Equal(@event.SchemaId.Id, actual.SchemaId.Id); } [Fact] public async Task Should_enrich_with_old_data_if_updated() { - var ctx = Context(); - - var @event = new ContentUpdated { AppId = ctx.AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatch }; + var ctx = Context().ToRulesContext(); + var @event = new ContentUpdated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; var envelope = Envelope.Create(@event).SetEventStreamNumber(12); var dataNow = new ContentData(); var dataOld = new ContentData(); - A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 12, CancellationToken)) - .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch, Version = 12, Data = dataNow, Id = @event.ContentId }); + A.CallTo(() => contentLoader.GetAsync(AppId.Id, @event.ContentId, 12, CancellationToken)) + .Returns(new ContentEntity { AppId = AppId, SchemaId = schemaMatching, Version = 12, Data = dataNow, Id = @event.ContentId }); + + A.CallTo(() => contentLoader.GetAsync(AppId.Id, @event.ContentId, 11, CancellationToken)) + .Returns(new ContentEntity { AppId = AppId, SchemaId = schemaMatching, Version = 11, Data = dataOld }); + + var actuals = await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + var actual = actuals.Single() as EnrichedContentEvent; + + Assert.Same(dataNow, actual!.Data); + Assert.Same(dataOld, actual!.DataOld); + } + + [Fact] + public async Task Should_query_references_if_filters_match() + { + var ctx = ReferencingContext(100, true); + + var @event = new ContentUpdated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 11, CancellationToken)) - .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch, Version = 11, Data = dataOld }); + SetupData(@event, 12); + + A.CallTo(() => contentRepository.StreamReferencing(AppId.Id, @event.ContentId, 100, CancellationToken)) + .Returns(new List + { + new ContentEntity { SchemaId = schemaMatching }, + new ContentEntity { SchemaId = schemaMatching } + }.ToAsyncEnumerable()); var actual = await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); - var enrichedEvent = actual.Single() as EnrichedContentEvent; + Assert.Equal(2, actual.OfType().Count(x => x.Type == EnrichedContentEventType.ReferenceUpdated)); + } + + [Fact] + public async Task Should_not_query_references_if_filter_does_not_match() + { + var ctx = ReferencingContext(100, true, schemaNotMatching); + + var @event = new ContentUpdated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); + + SetupData(@event, 12); + + await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + + A.CallTo(contentRepository) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_query_references_if_extra_events_not_enabled() + { + var ctx = ReferencingContext(100, false, schemaMatching); + + var @event = new ContentUpdated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); + + SetupData(@event, 12); + + await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + + A.CallTo(contentRepository) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_query_references_if_number_of_events_is_null() + { + var ctx = ReferencingContext(null, false, schemaMatching); + + var @event = new ContentUpdated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); + + SetupData(@event, 12); - Assert.Same(dataNow, enrichedEvent!.Data); - Assert.Same(dataOld, enrichedEvent!.DataOld); + await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + + A.CallTo(contentRepository) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_query_references_if_number_of_events_is_zero() + { + var ctx = ReferencingContext(null, false, schemaMatching); + + var @event = new ContentUpdated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); + + SetupData(@event, 12); + + await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + + A.CallTo(contentRepository) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_query_references_if_created_event() + { + var ctx = ReferencingContext(100, true, schemaMatching); + + var @event = new ContentCreated { AppId = AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatching }; + var envelope = Envelope.Create(@event).SetEventStreamNumber(12); + + SetupData(@event, 12); + + await sut.CreateEnrichedEventsAsync(envelope, ctx, CancellationToken).ToListAsync(CancellationToken); + + A.CallTo(contentRepository) + .MustNotHaveHappened(); } [Fact] @@ -229,9 +328,9 @@ public class ContentChangedTriggerHandlerTests : GivenContext { TestForTrigger(handleAll: false, schemaId: null, condition: null, action: ctx => { - var @event = new ContentCreated { SchemaId = schemaMatch }; + var @event = new ContentCreated { SchemaId = schemaMatching }; - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.False(actual); }); @@ -240,11 +339,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_trigger_precheck_if_handling_all_events() { - TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: ctx => + TestForTrigger(handleAll: true, schemaId: schemaMatching, condition: null, action: ctx => { - var @event = new ContentCreated { SchemaId = schemaMatch }; + var @event = new ContentCreated { SchemaId = schemaMatching }; - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.True(actual); }); @@ -253,11 +352,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_trigger_precheck_if_condition_is_empty() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: ctx => + TestForTrigger(handleAll: false, schemaId: schemaMatching, condition: string.Empty, action: ctx => { - var @event = new ContentCreated { SchemaId = schemaMatch }; + var @event = new ContentCreated { SchemaId = schemaMatching }; - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.True(actual); }); @@ -266,11 +365,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_not_trigger_precheck_if_schema_id_does_not_match() { - TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: ctx => + TestForTrigger(handleAll: false, schemaId: schemaNotMatching, condition: null, action: ctx => { - var @event = new ContentCreated { SchemaId = schemaMatch }; + var @event = new ContentCreated { SchemaId = schemaMatching }; - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.False(actual); }); @@ -281,9 +380,9 @@ public class ContentChangedTriggerHandlerTests : GivenContext { TestForTrigger(handleAll: false, schemaId: null, condition: null, action: ctx => { - var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + var @event = new EnrichedContentEvent { SchemaId = schemaMatching }; - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); @@ -292,11 +391,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_trigger_check_if_handling_all_events() { - TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: ctx => + TestForTrigger(handleAll: true, schemaId: schemaMatching, condition: null, action: ctx => { - var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + var @event = new EnrichedContentEvent { SchemaId = schemaMatching }; - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -305,11 +404,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_trigger_check_if_condition_is_empty() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: ctx => + TestForTrigger(handleAll: false, schemaId: schemaMatching, condition: string.Empty, action: ctx => { - var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + var @event = new EnrichedContentEvent { SchemaId = schemaMatching }; - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -318,11 +417,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_trigger_check_if_condition_matchs() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "true", action: ctx => + TestForTrigger(handleAll: false, schemaId: schemaMatching, condition: "true", action: ctx => { - var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + var @event = new EnrichedContentEvent { SchemaId = schemaMatching }; - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -331,11 +430,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_not_trigger_check_if_schema_id_does_not_match() { - TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: ctx => + TestForTrigger(handleAll: false, schemaId: schemaNotMatching, condition: null, action: ctx => { - var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + var @event = new EnrichedContentEvent { SchemaId = schemaMatching }; - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); @@ -344,11 +443,11 @@ public class ContentChangedTriggerHandlerTests : GivenContext [Fact] public void Should_not_trigger_check_if_condition_does_not_match() { - TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "false", action: ctx => + TestForTrigger(handleAll: false, schemaId: schemaMatching, condition: "false", action: ctx => { - var @event = new EnrichedContentEvent { SchemaId = schemaMatch }; + var @event = new EnrichedContentEvent { SchemaId = schemaMatching }; - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); @@ -356,14 +455,17 @@ public class ContentChangedTriggerHandlerTests : GivenContext private void TestForTrigger(bool handleAll, NamedId? schemaId, string? condition, Action action) { - var trigger = new ContentChangedTriggerV2 { HandleAll = handleAll }; + var trigger = new ContentChangedTriggerV2 + { + HandleAll = handleAll + }; if (schemaId != null) { trigger = trigger with { Schemas = ReadonlyList.Create( - new ContentChangedTriggerSchemaV2 + new SchemaCondition { SchemaId = schemaId.Id, Condition = condition @@ -385,6 +487,47 @@ public class ContentChangedTriggerHandlerTests : GivenContext } } + private void SetupData(ContentEvent @event, int version) + { + var dataNow = new ContentData(); + var dataOld = new ContentData(); + + A.CallTo(() => contentLoader.GetAsync(AppId.Id, @event.ContentId, version, CancellationToken)) + .Returns(new ContentEntity { AppId = AppId, SchemaId = schemaMatching, Version = version, Data = dataNow, Id = @event.ContentId }); + + A.CallTo(() => contentLoader.GetAsync(AppId.Id, @event.ContentId, version, CancellationToken)) + .Returns(new ContentEntity { AppId = AppId, SchemaId = schemaMatching, Version = version - 1, Data = dataOld }); + } + + private RulesContext ReferencingContext(int? maxEvents, bool allowExtra, NamedId? schemaId = null) + { + schemaId ??= schemaMatching; + + var trigger = new ContentChangedTriggerV2 + { + ReferencedSchemas = new List + { + new SchemaCondition + { + SchemaId = schemaId.Id, + } + }.ToReadonlyList() + }; + + return new RulesContext + { + AppId = AppId, + MaxEvents = maxEvents, + IncludeSkipped = true, + IncludeStale = true, + Rules = new Dictionary + { + [DomainId.NewGuid()] = new Rule(trigger, A.Fake()) + }.ToReadonlyDictionary(), + AllowExtraEvents = allowExtra + }; + } + private RuleContext Context(RuleTrigger? trigger = null) { trigger ??= new ContentChangedTriggerV2(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs index bfa279923..4f634e9b8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs @@ -49,7 +49,7 @@ public class GuardRuleTests : GivenContext, IClassFixture { Trigger = new ContentChangedTriggerV2 { - Schemas = ReadonlyList.Empty() + Schemas = ReadonlyList.Empty() }, Action = null! }); @@ -65,7 +65,7 @@ public class GuardRuleTests : GivenContext, IClassFixture { Trigger = new ContentChangedTriggerV2 { - Schemas = ReadonlyList.Empty() + Schemas = ReadonlyList.Empty() }, Action = new TestAction { @@ -99,7 +99,7 @@ public class GuardRuleTests : GivenContext, IClassFixture { Trigger = new ContentChangedTriggerV2 { - Schemas = ReadonlyList.Empty() + Schemas = ReadonlyList.Empty() }, Action = new TestAction { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs index 2fa5a1540..df86101d6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs @@ -22,7 +22,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture() + Schemas = ReadonlyList.Empty() }; var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider); @@ -88,7 +88,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture Assert.Equal(0, sut.Version); - A.CallTo(() => ruleEnqueuer.EnqueueAsync(sut.Snapshot.RuleDef, sut.Snapshot.Id, + A.CallTo(() => ruleEnqueuer.EnqueueAsync(sut.Snapshot.Id, sut.Snapshot.RuleDef, A>.That.Matches(x => x.Payload is RuleManuallyTriggered))) .MustHaveHappened(); } 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 ecb5d42a2..b55c692e6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs @@ -66,7 +66,7 @@ public class ManualTriggerHandlerTests { var @event = new RuleManuallyTriggered(); - Assert.True(sut.Trigger(Envelope.Create(@event), default)); + Assert.True(sut.Trigger(Envelope.Create(@event), null!)); } [Fact] @@ -74,6 +74,6 @@ public class ManualTriggerHandlerTests { var @event = new EnrichedUsageExceededEvent(); - Assert.True(sut.Trigger(@event, default)); + Assert.True(sut.Trigger(@event, null!)); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs index 0b891d0a8..c52bcd2d8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs @@ -6,7 +6,6 @@ // ========================================================================== using NodaTime; -using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; @@ -15,13 +14,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries; public class RuleEnricherTests : GivenContext { - private readonly IRuleEventRepository ruleEventRepository = A.Fake(); + private readonly IRuleUsageTracker ruleUsageTracker = A.Fake(); private readonly IRequestCache requestCache = A.Fake(); private readonly RuleEnricher sut; public RuleEnricherTests() { - sut = new RuleEnricher(ruleEventRepository, requestCache); + sut = new RuleEnricher(ruleUsageTracker, requestCache); } [Fact] @@ -31,10 +30,8 @@ public class RuleEnricherTests : GivenContext var actual = await sut.EnrichAsync(source, ApiContext, CancellationToken); - Assert.Equal(0, actual.NumFailed); Assert.Equal(0, actual.NumSucceeded); - - Assert.Null(actual.LastExecuted); + Assert.Equal(0, actual.NumFailed); A.CallTo(() => requestCache.AddDependency(source.UniqueId, source.Version)) .MustHaveHappened(); @@ -48,23 +45,26 @@ public class RuleEnricherTests : GivenContext { var source = CreateRule(); - var stats = new RuleStatistics + var stats = new Dictionary { - RuleId = source.Id, - NumFailed = 12, - NumSucceeded = 17, - LastExecuted = SystemClock.Instance.GetCurrentInstant() + [source.Id] = new RuleCounters(42, 17, 12) }; - A.CallTo(() => ruleEventRepository.QueryStatisticsByAppAsync(AppId.Id, CancellationToken)) - .Returns(new List { stats }); + A.CallTo(() => ruleUsageTracker.GetTotalByAppAsync(AppId.Id, CancellationToken)) + .Returns(stats); - await sut.EnrichAsync(source, ApiContext, CancellationToken); + var actual = await sut.EnrichAsync(source, ApiContext, CancellationToken); + + Assert.Equal(17, actual.NumSucceeded); + Assert.Equal(12, actual.NumFailed); A.CallTo(() => requestCache.AddDependency(source.UniqueId, source.Version)) .MustHaveHappened(); - A.CallTo(() => requestCache.AddDependency(stats.LastExecuted)) + A.CallTo(() => requestCache.AddDependency(17L)) + .MustHaveHappened(); + + A.CallTo(() => requestCache.AddDependency(12L)) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerWorkerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerWorkerTests.cs index ac6436c4d..9a664748a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerWorkerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerWorkerTests.cs @@ -19,6 +19,7 @@ public class RuleDequeuerWorkerTests private readonly IClock clock = A.Fake(); private readonly IRuleEventRepository ruleEventRepository = A.Fake(); private readonly IRuleService ruleService = A.Fake(); + private readonly IRuleUsageTracker ruleUsageTracker = A.Fake(); private readonly ILogger log = A.Dummy>(); private readonly RuleDequeuerWorker sut; @@ -27,7 +28,7 @@ public class RuleDequeuerWorkerTests A.CallTo(() => clock.GetCurrentInstant()) .Returns(SystemClock.Instance.GetCurrentInstant().WithoutMs()); - sut = new RuleDequeuerWorker(ruleService, ruleEventRepository, log) + sut = new RuleDequeuerWorker(ruleService, ruleUsageTracker, ruleEventRepository, log) { Clock = clock }; @@ -114,26 +115,27 @@ public class RuleDequeuerWorkerTests var now = clock.GetCurrentInstant(); - Instant? nextCall = null; - - if (minutes > 0) - { - nextCall = now.Plus(Duration.FromMinutes(minutes)); - } - await sut.HandleAsync(@event); if (actual == RuleResult.Failed) { A.CallTo(log).Where(x => x.Method.Name == "Log" && x.GetArgument(0) == LogLevel.Warning) .MustHaveHappened(); + + A.CallTo(() => ruleUsageTracker.TrackAsync(@event.Job.AppId, @event.Job.RuleId, now.ToDateOnly(), 0, 0, 1, A._)) + .MustHaveHappened(); } else { A.CallTo(log).Where(x => x.Method.Name == "Log" && x.GetArgument(0) == LogLevel.Warning) .MustNotHaveHappened(); + + A.CallTo(() => ruleUsageTracker.TrackAsync(@event.Job.AppId, @event.Job.RuleId, now.ToDateOnly(), 0, 1, 0, A._)) + .MustHaveHappened(); } + var nextCall = minutes > 0 ? now.Plus(Duration.FromMinutes(minutes)) : (Instant?)null; + A.CallTo(() => ruleEventRepository.UpdateAsync(@event.Job, A.That.Matches(x => x.Elapsed == requestElapsed && @@ -158,9 +160,11 @@ public class RuleDequeuerWorkerTests var job = new RuleJob { Id = id, + AppId = DomainId.NewGuid(), ActionData = actionData, ActionName = actionName, - Created = clock.GetCurrentInstant() + Created = clock.GetCurrentInstant(), + RuleId = DomainId.NewGuid() }; A.CallTo(() => @event.Id).Returns(id); 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 cb63a9487..947ae910b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs @@ -24,9 +24,9 @@ namespace Squidex.Domain.Apps.Entities.Rules; public class RuleEnqueuerTests : GivenContext { 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 IRuleUsageTracker ruleUsageTracker = A.Fake(); private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); private readonly RuleEnqueuer sut; @@ -40,10 +40,11 @@ public class RuleEnqueuerTests : GivenContext { var options = Options.Create(new RuleOptions()); - sut = new RuleEnqueuer(cache, localCache, + sut = new RuleEnqueuer(cache, A.Fake(), AppProvider, ruleEventRepository, ruleService, + ruleUsageTracker, options, A.Fake>()); } @@ -51,50 +52,66 @@ public class RuleEnqueuerTests : GivenContext [Fact] public void Should_return_wildcard_filter_for_events_filter() { - IEventConsumer consumer = sut; - - Assert.Equal(".*", consumer.EventsFilter); + Assert.Equal(".*", ((IEventConsumer)sut).EventsFilter); } [Fact] public async Task Should_do_nothing_on_clear() { - IEventConsumer consumer = sut; - - await consumer.ClearAsync(); + await ((IEventConsumer)sut).ClearAsync(); } [Fact] public void Should_return_type_name_for_name() { - IEventConsumer consumer = sut; + Assert.Equal(nameof(RuleEnqueuer), ((IEventConsumer)sut).Name); + } - Assert.Equal(nameof(RuleEnqueuer), consumer.Name); + [Fact] + public void Should_process_in_batches() + { + Assert.True(sut.BatchSize > 1); } [Fact] - public async Task Should_not_insert_job_if_null() + public async Task Should_not_enqueue_event_if_job_is_null() { var @event = Envelope.Create(new ContentCreated { AppId = AppId }); var rule = CreateRule(); - var job = new RuleJob - { - Created = now - }; + A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default)) + .Returns(Enumerable.Repeat(new JobResult(), 1).ToAsyncEnumerable()); + + await sut.EnqueueAsync(rule.Id, rule.RuleDef, @event); + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_enqueue_event_if_it_has_no_job() + { + var @event = Envelope.Create(new ContentCreated { AppId = AppId }); + + var rule = CreateRule(); A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default)) - .Returns(new List { new JobResult() }.ToAsyncEnumerable()); + .Returns(Enumerable.Repeat(new JobResult + { + Rule = rule.RuleDef, + RuleId = rule.Id, + SkipReason = SkipReason.WrongEvent + }, 1).ToAsyncEnumerable()); - await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); + await sut.EnqueueAsync(rule.Id, rule.RuleDef, @event); - A.CallTo(() => ruleEventRepository.EnqueueAsync(A._, (Exception?)null, default)) + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) .MustNotHaveHappened(); } [Fact] - public async Task Should_not_insert_job_if_job_has_a_skip_reason() + public async Task Should_enqueue_event_with_successful_job() { var @event = Envelope.Create(new ContentCreated { AppId = AppId }); @@ -102,20 +119,35 @@ public class RuleEnqueuerTests : GivenContext var job = new RuleJob { + AppId = AppId.Id, + ActionData = string.Empty, + ActionName = string.Empty, Created = now }; + RuleEventWrite[]? writes = null; + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) + .Invokes(x => writes = x.GetArgument>(0)?.ToArray()); + A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default)) - .Returns(new List { new JobResult { Job = job, SkipReason = SkipReason.TooOld } }.ToAsyncEnumerable()); + .Returns(Enumerable.Repeat(new JobResult + { + Job = job, + Rule = rule.RuleDef, + RuleId = rule.Id + }, 1).ToAsyncEnumerable()); - await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); + await sut.EnqueueAsync(rule.Id, rule.RuleDef, @event); - A.CallTo(() => ruleEventRepository.EnqueueAsync(A._, (Exception?)null, default)) - .MustNotHaveHappened(); + Assert.Equal(new[] { new RuleEventWrite(job, job.Created) }, writes); + + A.CallTo(() => ruleUsageTracker.TrackAsync(AppId.Id, rule.Id, now.ToDateOnly(), 1, 0, 0, default)) + .MustHaveHappened(); } [Fact] - public async Task Should_update_repository_if_enqueing() + public async Task Should_enqueue_event_with_failed_job() { var @event = Envelope.Create(new ContentCreated { AppId = AppId }); @@ -123,85 +155,150 @@ public class RuleEnqueuerTests : GivenContext var job = new RuleJob { + AppId = AppId.Id, + ActionData = string.Empty, + ActionName = string.Empty, Created = now }; + RuleEventWrite[]? writes = null; + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) + .Invokes(x => writes = x.GetArgument>(0)?.ToArray()); + A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default)) - .Returns(new List { new JobResult { Job = job } }.ToAsyncEnumerable()); + .Returns(Enumerable.Repeat(new JobResult + { + Job = job, + Rule = rule.RuleDef, + RuleId = rule.Id, + SkipReason = SkipReason.Failed + }, 1).ToAsyncEnumerable()); - await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); + await sut.EnqueueAsync(rule.Id, rule.RuleDef, @event); - A.CallTo(() => ruleEventRepository.EnqueueAsync(job, (Exception?)null, default)) + Assert.Equal(new[] { new RuleEventWrite(job) }, writes); + + A.CallTo(() => ruleUsageTracker.TrackAsync(AppId.Id, rule.Id, now.ToDateOnly(), 1, 0, 1, default)) .MustHaveHappened(); } [Fact] - public async Task Should_update_repository_if_enqueing_broken_job() + public async Task Should_handle_event_with_successful_job() { var @event = Envelope.Create(new ContentCreated { AppId = AppId }); - var rule = CreateRule(); - var job = new RuleJob { + AppId = AppId.Id, + ActionData = string.Empty, + ActionName = string.Empty, Created = now }; - A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default)) - .Returns(new List { new JobResult { Job = job, SkipReason = SkipReason.Failed } }.ToAsyncEnumerable()); + SetupRules(@event, job, default); + + RuleEventWrite[]? writes = null; + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) + .Invokes(x => writes = x.GetArgument>(0)?.ToArray()); + + await sut.On(new[] { @event }); - await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); + Assert.Equal(new[] { new RuleEventWrite(job, job.Created) }, writes); - A.CallTo(() => ruleEventRepository.EnqueueAsync(job, (Exception?)null, default)) + A.CallTo(() => ruleUsageTracker.TrackAsync(AppId.Id, A._, now.ToDateOnly(), 1, 0, 0, default)) .MustHaveHappened(); } [Fact] - public async Task Should_update_repository_with_jobs_from_service() + public async Task Should_handle_event_with_failed_job() { var @event = Envelope.Create(new ContentCreated { AppId = AppId }); - var job1 = new RuleJob + var job = new RuleJob { + AppId = AppId.Id, + ActionData = string.Empty, + ActionName = string.Empty, Created = now }; - SetupRules(@event, job1); + SetupRules(@event, job, SkipReason.Failed); + + RuleEventWrite[]? writes = null; + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) + .Invokes(x => writes = x.GetArgument>(0)?.ToArray()); + + await sut.On(new[] { @event }); - await sut.On(@event); + Assert.Equal(new[] { new RuleEventWrite(job) }, writes); - A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, (Exception?)null, default)) + A.CallTo(() => ruleUsageTracker.TrackAsync(AppId.Id, A._, now.ToDateOnly(), 1, 0, 1, default)) .MustHaveHappened(); } [Fact] - public async Task Should_not_eqneue_if_event_restored() + public async Task Should_not_handle_restored_event() { var @event = Envelope.Create(new ContentCreated { AppId = AppId }); - var job1 = new RuleJob { Created = now }; + var job = new RuleJob + { + AppId = AppId.Id, + ActionData = string.Empty, + ActionName = string.Empty, + Created = now + }; - SetupRules(@event, job1); + SetupRules(@event, job, default); - await sut.On(@event.SetRestored(true)); + await sut.On(new[] { @event.SetRestored(true) }); - A.CallTo(() => ruleEventRepository.EnqueueAsync(A._, A._, default)) + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) .MustNotHaveHappened(); } - private void SetupRules(Envelope @event, RuleJob job1) + [Fact] + public async Task Should_handle_events_in_batches() { - var rule1 = CreateRule(); - var rule2 = CreateRule(); + var @event = Envelope.Create(new ContentCreated { AppId = AppId }); - A.CallTo(() => AppProvider.GetRulesAsync(AppId.Id, A._)) - .Returns(new List { rule1, rule2 }); + var job = new RuleJob + { + AppId = AppId.Id, + ActionData = string.Empty, + ActionName = string.Empty, + Created = now + }; + + SetupRules(@event, job, default); + + await sut.On(Enumerable.Repeat(@event, 10)); + + A.CallTo(() => ruleEventRepository.EnqueueAsync(A>._, default)) + .MustHaveHappenedOnceExactly(); - A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule1), default)) - .Returns(new List { new JobResult { Job = job1 } }.ToAsyncEnumerable()); + A.CallTo(ruleUsageTracker) + .MustHaveHappenedANumberOfTimesMatching(x => x == 10); + } + + private void SetupRules(Envelope @event, RuleJob job, SkipReason skipReason) + { + var rule = CreateRule(); - A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule2), default)) - .Returns(new List().ToAsyncEnumerable()); + A.CallTo(() => AppProvider.GetRulesAsync(AppId.Id, A._)) + .Returns(new List { rule }); + + A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default)) + .Returns(Enumerable.Repeat(new JobResult + { + Job = job, + Rule = rule.RuleDef, + RuleId = rule.Id, + SkipReason = skipReason + }, 1).ToAsyncEnumerable()); } private static RuleEntity CreateRule() @@ -211,11 +308,11 @@ public class RuleEnqueuerTests : GivenContext return new RuleEntity { RuleDef = rule, Id = DomainId.NewGuid() }; } - private static RuleContext MatchingContext(RuleEntity rule) + private static RulesContext MatchingContext(RuleEntity rule) { // These two properties must not be set to true for performance reasons. - return A.That.Matches(x => - x.Rule == rule.RuleDef && + return A.That.Matches(x => + x.Rules.Values.Contains(rule.RuleDef) && !x.IncludeSkipped && !x.IncludeStale); } 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 6075802ed..104302b7d 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 @@ -41,17 +41,16 @@ public class UsageTriggerHandlerTests [Fact] public async Task Should_create_enriched_event() { - var ctx = Context(); + var ctx = Context().ToRulesContext(); var @event = new AppUsageExceeded { CallsCurrent = 80, CallsLimit = 120 }; var envelope = Envelope.Create(@event); - var actual = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); - - var enrichedEvent = actual.Single() as EnrichedUsageExceededEvent; + var actuals = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); + var actual = actuals.Single() as EnrichedUsageExceededEvent; - Assert.Equal(@event.CallsCurrent, enrichedEvent!.CallsCurrent); - Assert.Equal(@event.CallsLimit, enrichedEvent!.CallsLimit); + Assert.Equal(@event.CallsCurrent, actual!.CallsCurrent); + Assert.Equal(@event.CallsLimit, actual!.CallsLimit); } [Fact] @@ -61,7 +60,7 @@ public class UsageTriggerHandlerTests var @event = new AppUsageExceeded(); - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.True(actual); } @@ -73,7 +72,7 @@ public class UsageTriggerHandlerTests var @event = new AppUsageExceeded { RuleId = ctx.RuleId }; - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.True(actual); } 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 92a5e5ac2..3a5e0c488 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs @@ -66,20 +66,19 @@ public class SchemaChangedTriggerHandlerTests [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type) { - var ctx = Context(appId: @event.AppId); + var ctx = Context(appId: @event.AppId).ToRulesContext(); var envelope = Envelope.Create(@event).SetEventStreamNumber(12); - var actual = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); + var actuals = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync(); + var actual = actuals.Single() as EnrichedSchemaEvent; - var enrichedEvent = actual.Single() as EnrichedSchemaEvent; - - Assert.Equal(type, enrichedEvent!.Type); - Assert.Equal(@event.Actor, enrichedEvent.Actor); - Assert.Equal(@event.AppId, enrichedEvent.AppId); - Assert.Equal(@event.AppId.Id, enrichedEvent.AppId.Id); - Assert.Equal(@event.SchemaId, enrichedEvent.SchemaId); - Assert.Equal(@event.SchemaId.Id, enrichedEvent.SchemaId.Id); + Assert.Equal(type, actual!.Type); + Assert.Equal(@event.Actor, actual.Actor); + Assert.Equal(@event.AppId, actual.AppId); + Assert.Equal(@event.AppId.Id, actual.AppId.Id); + Assert.Equal(@event.SchemaId, actual.SchemaId); + Assert.Equal(@event.SchemaId.Id, actual.SchemaId.Id); } [Fact] @@ -89,7 +88,7 @@ public class SchemaChangedTriggerHandlerTests { var @event = new SchemaCreated(); - var actual = sut.Trigger(Envelope.Create(@event), ctx); + var actual = sut.Trigger(Envelope.Create(@event), ctx.Rule.Trigger); Assert.True(actual); }); @@ -102,7 +101,7 @@ public class SchemaChangedTriggerHandlerTests { var @event = new EnrichedSchemaEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -115,7 +114,7 @@ public class SchemaChangedTriggerHandlerTests { var @event = new EnrichedSchemaEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.True(actual); }); @@ -128,7 +127,7 @@ public class SchemaChangedTriggerHandlerTests { var @event = new EnrichedSchemaEvent(); - var actual = sut.Trigger(@event, ctx); + var actual = sut.Trigger(@event, ctx.Rule.Trigger); Assert.False(actual); }); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index ad3fb5581..db27303ad 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -33,9 +33,7 @@ - - - + diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs index fe2aa9a6e..d30292c77 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs @@ -82,7 +82,7 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(2) }; - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit); await Assert.ThrowsAsync(() => Sut.AppendAsync(Guid.NewGuid(), streamName, 0, commit)); } @@ -104,8 +104,8 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(2) }; - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit1); - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit2); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit1); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit2); var readEvents1 = await QueryAsync(streamName); var readEvents2 = await QueryAllAsync(streamName); @@ -164,7 +164,7 @@ public abstract class EventStoreTests where T : IEventStore var readEvents = await QueryWithSubscriptionAsync(streamName, async () => { - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit1); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit1); }); var expected = new[] @@ -190,7 +190,7 @@ public abstract class EventStoreTests where T : IEventStore // Append and read in parallel. await QueryWithSubscriptionAsync(streamName, async () => { - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit1); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit1); }); var commit2 = new[] @@ -202,7 +202,7 @@ public abstract class EventStoreTests where T : IEventStore // Append and read in parallel. var readEventsFromPosition = await QueryWithSubscriptionAsync(streamName, async () => { - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit2); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit2); }); var expectedFromPosition = new[] @@ -247,7 +247,7 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(i * j) }; - await Sut.AppendAsync(Guid.NewGuid(), fullStreamName, commit1); + await Sut.AppendAsync(Guid.NewGuid(), fullStreamName, EtagVersion.Any, commit1); } }); }); @@ -266,7 +266,7 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(2) }; - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit); var firstRead = await QueryAsync(streamName); @@ -300,8 +300,8 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(4) }; - await Sut.AppendAsync(Guid.NewGuid(), streamName1, stream1Commit); - await Sut.AppendAsync(Guid.NewGuid(), streamName2, stream2Commit); + await Sut.AppendAsync(Guid.NewGuid(), streamName1, EtagVersion.Any, stream1Commit); + await Sut.AppendAsync(Guid.NewGuid(), streamName2, EtagVersion.Any, stream2Commit); var readEvents = await Sut.QueryManyAsync(new[] { streamName1, streamName2 }); @@ -337,9 +337,9 @@ public abstract class EventStoreTests where T : IEventStore for (var i = 0; i < events.Count / commitSize; i++) { - var commit = events.Skip(i * commitSize).Take(commitSize); + var commit = events.Skip(i * commitSize).Take(commitSize).ToArray(); - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray()); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit); } var allExpected = events.Select((x, i) => new StoredEvent(streamName, "Position", i, events[i])).ToArray(); @@ -369,9 +369,9 @@ public abstract class EventStoreTests where T : IEventStore for (var i = 0; i < events.Count / commitSize; i++) { - var commit = events.Skip(i * commitSize).Take(commitSize); + var commit = events.Skip(i * commitSize).Take(commitSize).ToArray(); - await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray()); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, commit); } var allExpected = events.Select((x, i) => new StoredEvent(streamName, "Position", i, events[i])).ToArray(); @@ -396,7 +396,7 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(2) }; - await Sut.AppendAsync(Guid.NewGuid(), streamName, events); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, events); IReadOnlyList? readEvents = null; @@ -429,7 +429,7 @@ public abstract class EventStoreTests where T : IEventStore CreateEventData(2) }; - await Sut.AppendAsync(Guid.NewGuid(), streamName, events); + await Sut.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, events); IReadOnlyList? readEvents = null; diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs index b921f4b0a..007bf486a 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs @@ -211,7 +211,7 @@ public sealed class MongoParallelInsertTests : IClassFixture(new MyEvent()), commitId)); } - await _.EventStore.AppendAsync(commitId, streamName, commitList); + await _.EventStore.AppendAsync(commitId, streamName, EtagVersion.Any, commitList); } if (i < iterations - 1) diff --git a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs index 96f867ce1..9a5becfd0 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs @@ -14,7 +14,7 @@ public class ApiUsageTrackerTests private readonly IUsageTracker usageTracker = A.Fake(); private readonly string key = Guid.NewGuid().ToString(); private readonly string category = Guid.NewGuid().ToString(); - private readonly DateTime date = DateTime.Today; + private readonly DateOnly date = DateTime.Today.ToDateOnly(); private readonly ApiUsageTracker sut; public ApiUsageTrackerTests() @@ -92,9 +92,9 @@ public class ApiUsageTrackerTests var dateFrom = date; var dateTo = dateFrom.AddDays(4); - var counters = new Dictionary> + var counters = new Dictionary> { - ["my-category"] = new List<(DateTime Date, Counters Counters)> + ["my-category"] = new List<(DateOnly Date, Counters Counters)> { (dateFrom.AddDays(0), Counters(0, 0, 0)), (dateFrom.AddDays(1), Counters(4, 100, 2048)), @@ -102,7 +102,7 @@ public class ApiUsageTrackerTests (dateFrom.AddDays(3), Counters(2, 60, 1024)), (dateFrom.AddDays(4), Counters(3, 30, 512)) }, - ["*"] = new List<(DateTime Date, Counters Counters)> + ["*"] = new List<(DateOnly Date, Counters Counters)> { (dateFrom.AddDays(0), Counters(1, 20, 128)), (dateFrom.AddDays(1), Counters(0, 0, 0)), @@ -118,7 +118,7 @@ public class ApiUsageTrackerTests [ApiUsageTracker.CounterTotalBytes] = 400 }; - A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", DateTime.Today, null, ct)) + A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", dateFrom, null, ct)) .Returns(forMonth); A.CallTo(() => usageTracker.QueryAsync($"{key}_API", dateFrom, dateTo, ct)) diff --git a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs index b3df53210..a4e53b8f4 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs @@ -15,7 +15,7 @@ public class BackgroundUsageTrackerTests private readonly CancellationToken ct; private readonly IUsageRepository usageStore = A.Fake(); private readonly string key = Guid.NewGuid().ToString(); - private readonly DateTime date = DateTime.Today; + private readonly DateOnly date = DateTime.Today.ToDateOnly(); private readonly BackgroundUsageTracker sut; public BackgroundUsageTrackerTests() @@ -84,7 +84,7 @@ public class BackgroundUsageTrackerTests [Fact] public async Task Should_sum_up_if_getting_monthly_calls() { - var dateFrom = new DateTime(date.Year, date.Month, 1); + var dateFrom = new DateOnly(date.Year, date.Month, 1); var dateTo = dateFrom.AddMonths(1).AddDays(-1); var originalData = new List @@ -144,9 +144,9 @@ public class BackgroundUsageTrackerTests var actual = await sut.QueryAsync(key, dateFrom, dateTo, ct); - var expected = new Dictionary> + var expected = new Dictionary> { - ["*"] = new List<(DateTime Date, Counters Counters)> + ["*"] = new List<(DateOnly Date, Counters Counters)> { (dateFrom.AddDays(0), new Counters()), (dateFrom.AddDays(1), new Counters()), @@ -179,9 +179,9 @@ public class BackgroundUsageTrackerTests var actual = await sut.QueryAsync(key, dateFrom, dateTo, ct); - var expected = new Dictionary> + var expected = new Dictionary> { - ["my-category"] = new List<(DateTime Date, Counters Counters)> + ["my-category"] = new List<(DateOnly Date, Counters Counters)> { (dateFrom.AddDays(0), Counters()), (dateFrom.AddDays(1), Counters(a: 10, b: 15)), @@ -189,7 +189,7 @@ public class BackgroundUsageTrackerTests (dateFrom.AddDays(3), Counters(a: 13, b: 18)), (dateFrom.AddDays(4), Counters(a: 15, b: 20)) }, - ["*"] = new List<(DateTime Date, Counters Counters)> + ["*"] = new List<(DateOnly Date, Counters Counters)> { (dateFrom.AddDays(0), Counters(a: 17, b: 22)), (dateFrom.AddDays(1), Counters()), diff --git a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs index dcd025481..7be5f8622 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs @@ -17,7 +17,7 @@ public class CachingUsageTrackerTests private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly string key = Guid.NewGuid().ToString(); private readonly string category = Guid.NewGuid().ToString(); - private readonly DateTime date = DateTime.Today; + private readonly DateOnly date = DateTime.Today.ToDateOnly(); private readonly IUsageTracker inner = A.Fake(); private readonly IUsageTracker sut; @@ -92,7 +92,7 @@ public class CachingUsageTrackerTests Assert.Same(counters, actual1); Assert.Same(counters, actual2); - A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today, category, ct)) + A.CallTo(() => inner.GetForMonthAsync(key, date, category, ct)) .MustHaveHappenedOnceExactly(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs index 1ce60ba2f..84df5a018 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Routing; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Billing; using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; namespace Squidex.Web.Pipeline; @@ -50,7 +51,7 @@ public class ApiCostsFilterTests : GivenContext SetupApp(); - A.CallTo(() => usageGate.IsBlockedAsync(App, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(App, A._, DateTime.Today.ToDateOnly(), default)) .Returns(true); await sut.OnActionExecutionAsync(actionContext, next); @@ -66,7 +67,7 @@ public class ApiCostsFilterTests : GivenContext SetupApp(); - A.CallTo(() => usageGate.IsBlockedAsync(App, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(App, A._, DateTime.Today.ToDateOnly(), default)) .Returns(false); await sut.OnActionExecutionAsync(actionContext, next); @@ -85,7 +86,7 @@ public class ApiCostsFilterTests : GivenContext Assert.True(isNextCalled); - A.CallTo(() => usageGate.IsBlockedAsync(App, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(App, A._, A._, default)) .MustNotHaveHappened(); } @@ -98,7 +99,7 @@ public class ApiCostsFilterTests : GivenContext Assert.True(isNextCalled); - A.CallTo(() => usageGate.IsBlockedAsync(App, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(App, A._, A._, default)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs index 78b087eba..796fbe28d 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs @@ -11,6 +11,7 @@ using NodaTime; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Billing; using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; namespace Squidex.Web.Pipeline; @@ -50,9 +51,9 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); - A.CallTo(() => usageGate.TrackRequestAsync(A._, A._, A._, A._, A._, A._, default)) + A.CallTo(() => usageGate.TrackRequestAsync(A._, A._, A._, A._, A._, A._, default)) .MustNotHaveHappened(); } @@ -68,7 +69,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, A._, A._, A._, default)) .MustNotHaveHappened(); @@ -84,7 +85,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, 13, A._, A._, default)) .MustHaveHappened(); @@ -101,7 +102,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, 13, A._, 1024, default)) .MustHaveHappened(); @@ -122,7 +123,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, 13, A._, 11, default)) .MustHaveHappened(); @@ -143,7 +144,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, 13, A._, 11, default)) .MustHaveHappened(); @@ -174,7 +175,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, 13, A._, 11, default)) .MustHaveHappened(); @@ -190,7 +191,7 @@ public class UsageMiddlewareTests : GivenContext Assert.True(isNextCalled); - var date = instant.ToDateTimeUtc().Date; + var date = instant.ToDateOnly(); A.CallTo(() => usageGate.TrackRequestAsync(App, A._, date, A._, A._, A._, default)) .MustNotHaveHappened(); diff --git a/frontend/src/app/features/rules/pages/rules/rule.component.html b/frontend/src/app/features/rules/pages/rules/rule.component.html index 991650598..ab8032b67 100644 --- a/frontend/src/app/features/rules/pages/rules/rule.component.html +++ b/frontend/src/app/features/rules/pages/rules/rule.component.html @@ -97,19 +97,16 @@