Browse Source

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
pull/978/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
38ed425b95
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      backend/i18n/frontend_en.json
  2. 8
      backend/i18n/frontend_it.json
  3. 8
      backend/i18n/frontend_nl.json
  4. 8
      backend/i18n/frontend_pt.json
  5. 8
      backend/i18n/frontend_zh.json
  6. 8
      backend/i18n/source/frontend_en.json
  7. 1
      backend/i18n/source/frontend_it.json
  8. 1
      backend/i18n/source/frontend_nl.json
  9. 2
      backend/i18n/source/frontend_pt.json
  10. 1
      backend/i18n/source/frontend_zh.json
  11. 4
      backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs
  12. 3
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedContentEventType.cs
  13. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaCondition.cs
  15. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs
  16. 7
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs
  17. 23
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs
  18. 32
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs
  19. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs
  20. 384
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
  22. 47
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs
  23. 10
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  24. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  25. 8
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs
  26. 6
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs
  27. 6
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs
  28. 37
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs
  29. 13
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs
  30. 42
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs
  31. 89
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs
  32. 11
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs
  33. 12
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetStats.cs
  34. 12
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  35. 15
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  36. 24
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs
  37. 13
      backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs
  38. 145
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Assets.cs
  39. 140
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs
  40. 125
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  41. 11
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs
  42. 124
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  43. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  44. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs
  45. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
  46. 8
      backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs
  47. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs
  48. 35
      backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleUsageTracker.cs
  49. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs
  50. 24
      backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs
  51. 32
      backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs
  52. 24
      backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs
  53. 9
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs
  54. 79
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  55. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs
  56. 90
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleQueueWriter.cs
  57. 76
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  58. 74
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs
  59. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs
  60. 38
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerWorker.cs
  61. 9
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs
  62. 11
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs
  63. 17
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs
  64. 6
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs
  65. 18
      backend/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs
  66. 49
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  67. 3
      backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs
  68. 10
      backend/src/Squidex.Infrastructure/InstantExtensions.cs
  69. 14
      backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs
  70. 2
      backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs
  71. 10
      backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs
  72. 20
      backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  73. 8
      backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  74. 8
      backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs
  75. 2
      backend/src/Squidex.Infrastructure/UsageTracking/IUsageRepository.cs
  76. 8
      backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs
  77. 2
      backend/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs
  78. 4
      backend/src/Squidex.Infrastructure/UsageTracking/UsageUpdate.cs
  79. 30
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  80. 1
      backend/src/Squidex.Web/Pipeline/IgnoreCacheFilterAttribute.cs
  81. 7
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  82. 1
      backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs
  83. 2
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  84. 15
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs
  85. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs
  86. 6
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs
  87. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedRuleTriggerDto.cs
  88. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CommentRuleTriggerDto.cs
  89. 17
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs
  90. 37
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs
  91. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ManualRuleTriggerDto.cs
  92. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/SchemaChangedRuleTriggerDto.cs
  93. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs
  94. 57
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs
  95. 9
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs
  96. 9
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs
  97. 9
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/BooleanFieldPropertiesDto.cs
  98. 9
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentFieldPropertiesDto.cs
  99. 9
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentsFieldPropertiesDto.cs
  100. 9
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/DateTimeFieldPropertiesDto.cs

8
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",

8
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",

8
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",

8
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",

8
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": "创建并添加字段",

8
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",

1
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",

1
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",

2
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",

1
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": "隐藏资源",

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

3
backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedContentEventType.cs

@ -14,5 +14,6 @@ public enum EnrichedContentEventType
Published,
StatusChanged,
Updated,
Unpublished
Unpublished,
ReferenceUpdated
}

4
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<ContentChangedTriggerSchemaV2>? Schemas { get; init; }
public ReadonlyList<SchemaCondition>? Schemas { get; init; }
public ReadonlyList<SchemaCondition>? ReferencedSchemas { get; init; }
public bool HandleAll { get; init; }

2
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchemaV2.cs → 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; }

5
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<JobResult> CreateSnapshotJobsAsync(RuleContext context,
CancellationToken ct = default);
IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RuleContext context,
IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RulesContext context,
CancellationToken ct = default);
Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job,

7
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<EnrichedEvent>();
}
IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RulesContext context,
CancellationToken ct);
string? GetName(AppEvent @event)
@ -34,12 +35,12 @@ public interface IRuleTriggerHandler
return null;
}
bool Trigger(Envelope<AppEvent> @event, RuleContext context)
bool Trigger(Envelope<AppEvent> @event, RuleTrigger trigger)
{
return true;
}
bool Trigger(EnrichedEvent @event, RuleContext context)
bool Trigger(EnrichedEvent @event, RuleTrigger trigger)
{
return true;
}

23
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

32
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<DomainId> AppId { get; init; }
public ReadonlyDictionary<DomainId, Rule> 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<DomainId> 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<DomainId, Rule>
{
[RuleId] = Rule
}.ToReadonlyDictionary()
};
}
}

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

384
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<RuleService> 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<JobResult> 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<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
public IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @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<JobResult>();
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<JobResult> CreateJobs(Envelope<IEvent> @event, RulesContext context, List<RuleState> 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<JobResult> jobs, Envelope<IEvent> @event, RuleContext context,
CancellationToken ct)
{
try
{
var skipReason = SkipReason.None;
var rule = context.Rule;
var typed = @event.To<AppEvent>();
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<IRuleTriggerHandler, List<RuleState>>? matchingRules = null;
var typed = @event.To<AppEvent>();
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<JobResult> CreateTriggerJobs(Envelope<AppEvent> @event, IRuleTriggerHandler triggerHandler, List<RuleState> 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<JobResult> CreateEventJobs(Envelope<AppEvent> @event, EnrichedEvent enrichedEvent, IRuleTriggerHandler triggerHandler, List<RuleState> 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<JobResult> CreateJobAsync(IRuleActionHandler actionHandler, EnrichedEvent enrichedEvent, RuleContext context, Instant now)
private async Task<JobResult> 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<RuleAction>(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)
{

2
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs

@ -58,7 +58,7 @@ public static class JintExtensions
}
internal static ScriptExecutionContext<T> Extend<T>(this ScriptExecutionContext<T> context,
ScriptVars vars,
ScriptVars vars,
ScriptOptions options)
{
var engine = context.Engine;

47
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<T> : ScriptExecutionContext, ISchedul
{
private readonly TaskCompletionSource<T> tcs = new TaskCompletionSource<T>();
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<T> : ScriptExecutionContext, ISchedul
async Task ScheduleAsync()
{
TryStart();
try
{
TryStart();
await action(this, cancellationToken);
TryComplete(default!);
@ -86,21 +83,20 @@ public sealed class ScriptExecutionContext<T> : 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<T> : 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<T> : 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<T> : ScriptExecutionContext, ISchedul
{
tcs.TrySetResult(result);
}
Debug.WriteLine(pendingTasks);
}
}

10
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -109,6 +109,12 @@ public sealed class MongoContentCollection : MongoRepositoryBase<MongoContentEnt
return queryAsStream.StreamAll(appId, schemaIds, ct);
}
public IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId reference, int take,
CancellationToken ct)
{
return queryReferrers.StreamReferencing(appId, reference, take, ct);
}
public IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
CancellationToken ct)
{
@ -231,12 +237,12 @@ public sealed class MongoContentCollection : MongoRepositoryBase<MongoContentEnt
}
}
public async Task<bool> HasReferrersAsync(DomainId appId, DomainId contentId,
public async Task<bool> 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);
}
}

12
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -75,6 +75,12 @@ public partial class MongoContentRepository : MongoBase<MongoContentEntity>, ICo
return collectionComplete.StreamAll(appId, schemaIds, ct);
}
public IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId reference, int take,
CancellationToken ct = default)
{
return collectionComplete.StreamReferencing(appId, reference, take, ct);
}
public IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
CancellationToken ct = default)
{
@ -133,16 +139,16 @@ public partial class MongoContentRepository : MongoBase<MongoContentEntity>, ICo
}
}
public Task<bool> HasReferrersAsync(DomainId appId, DomainId contentId, SearchScope scope,
public Task<bool> 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);
}
}

8
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<CreateIndexModel<MongoContentEntity>> CreateIndexes()
{
yield return new CreateIndexModel<MongoContentEntity>(Index
.Ascending(x => x.IndexedAppId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.IndexedSchemaId));
}
public async IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
[EnumeratorCancellation] CancellationToken ct)
{

6
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs

@ -150,7 +150,7 @@ internal sealed class QueryByQuery : OperationBase
}
private static (FilterDefinition<MongoContentEntity>, bool) CreateFilter(DomainId appId, IEnumerable<DomainId> schemaIds, ClrQuery? query,
DomainId referenced, RefToken? createdBy)
DomainId reference, RefToken? createdBy)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -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;
}

6
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs

@ -165,7 +165,7 @@ internal sealed class QueryInDedicatedCollection : MongoBase<MongoContentEntity>
}
private static FilterDefinition<MongoContentEntity> CreateFilter(ClrQuery? query,
DomainId referenced, RefToken? createdBy)
DomainId reference, RefToken? createdBy)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -183,9 +183,9 @@ internal sealed class QueryInDedicatedCollection : MongoBase<MongoContentEntity>
filters.Add(query.Filter.BuildFilter<MongoContentEntity>());
}
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)

37
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<bool> CheckExistsAsync(DomainId appId, DomainId contentId,
public async Task<bool> 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<IContentEntity> 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<MongoContentEntity> 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));
}
}

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

42
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<MongoRuleEventEntity>, 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<MongoRuleEven
protected override async Task SetupCollectionAsync(IMongoCollection<MongoRuleEventEntity> collection,
CancellationToken ct)
{
await statisticsCollection.InitializeAsync(ct);
await collection.Indexes.CreateManyAsync(new[]
{
new CreateIndexModel<MongoRuleEventEntity>(
@ -58,8 +52,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
async Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
await statisticsCollection.DeleteAppAsync(app.Id, ct);
await Collection.DeleteManyAsync(Filter.Eq(x => x.AppId, app.Id), ct);
}
@ -106,14 +98,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
return Collection.UpdateOneAsync(x => 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<MongoRuleEven
Guard.NotNull(job);
Guard.NotNull(update);
return Task.WhenAll(
UpdateStatisticsAsync(job, update, ct),
UpdateEventAsync(job, update, ct));
}
private Task UpdateEventAsync(RuleJob job, RuleJobUpdate update,
CancellationToken ct = default)
{
return Collection.UpdateOneAsync(x => x.JobId == job.Id,
Update
.Set(x => x.Result, update.ExecutionResult)
@ -168,22 +144,14 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
cancellationToken: ct);
}
private async Task UpdateStatisticsAsync(RuleJob job, RuleJobUpdate update,
public async Task EnqueueAsync(List<RuleEventWrite> 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<IReadOnlyList<RuleStatistics>> QueryStatisticsByAppAsync(DomainId appId,
CancellationToken ct = default)
{
return statisticsCollection.QueryByAppAsync(appId, ct);
}
}

89
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs

@ -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<RuleStatistics>
{
static MongoRuleStatisticsCollection()
{
BsonClassMap.RegisterClassMap<RuleStatistics>(cm =>
{
cm.AutoMap();
cm.SetIgnoreExtraElements(true);
});
}
public MongoRuleStatisticsCollection(IMongoDatabase database)
: base(database)
{
}
protected override string CollectionName()
{
return "RuleStatistics";
}
protected override Task SetupCollectionAsync(IMongoCollection<RuleStatistics> collection,
CancellationToken ct)
{
return collection.Indexes.CreateOneAsync(
new CreateIndexModel<RuleStatistics>(
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<IReadOnlyList<RuleStatistics>> 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);
}
}

11
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @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);
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetStats.cs

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

12
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<State> 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<string>? Tags { get; set; }
}
public AssetUsageTracker(IUsageGate usageGate, IAssetLoader assetLoader, ITagService tagService,
public AssetUsageTracker(
IAssetLoader assetLoader,
IAssetUsageTracker assetUsageTracker,
ITagService tagService,
ISnapshotStore<State> 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);
}
}

15
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<Envelope<IEvent>> 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<IEvent> @event)
private static DateOnly GetDate(Envelope<IEvent> @event)
{
return @event.Headers.Timestamp().ToDateTimeUtc().Date;
return @event.Headers.Timestamp().ToDateOnly();
}
}

24
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<IReadOnlyList<AssetStats>> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate,
Task<IReadOnlyList<AssetStats>> QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
Task<AssetCounters> GetTotalByAppAsync(DomainId appId,
CancellationToken ct = default);
Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate,
Task<AssetCounters> GetTotalByTeamAsync(DomainId teamId,
CancellationToken ct = default);
Task<long> GetTotalSizeByAppAsync(DomainId appId,
Task TrackAsync(DomainId appId, DateOnly date, long fileSize, long count,
CancellationToken ct = default);
Task<long> 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);

13
backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs

@ -13,19 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Billing;
public interface IUsageGate
{
Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date,
Task<bool> 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,

145
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<AssetCounters> IAssetUsageTracker.GetTotalByAppAsync(DomainId appId,
CancellationToken ct)
{
return GetTotalForAssetsAsync(AppAssetsKey(appId), ct);
}
Task<AssetCounters> IAssetUsageTracker.GetTotalByTeamAsync(DomainId teamId,
CancellationToken ct)
{
return GetTotalForAssetsAsync(TeamAssetsKey(teamId), ct);
}
Task<IReadOnlyList<AssetStats>> IAssetUsageTracker.QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct)
{
return QueryForAssetsAsync(AppAssetsKey(appId), fromDate, toDate, ct);
}
Task<IReadOnlyList<AssetStats>> IAssetUsageTracker.QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct)
{
return QueryForAssetsAsync(TeamAssetsKey(teamId), fromDate, toDate, ct);
}
private async Task<AssetCounters> GetTotalForAssetsAsync(string key,
CancellationToken ct)
{
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct);
return GetAssetCounters(counters);
}
private async Task<IReadOnlyList<AssetStats>> QueryForAssetsAsync(string key, DateOnly fromDate, DateOnly toDate,
CancellationToken ct)
{
var result = new List<AssetStats>();
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<Task>
{
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";
}
}

140
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<IReadOnlyList<RuleStats>> IRuleUsageTracker.QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct)
{
return QueryForRulesAsync(AppRulesKey(appId), fromDate, toDate, ct);
}
Task<IReadOnlyList<RuleStats>> IRuleUsageTracker.QueryByTeamAsync(DomainId appId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct)
{
return QueryForRulesAsync(TeamRulesKey(appId), fromDate, toDate, ct);
}
async Task<IReadOnlyDictionary<DomainId, RuleCounters>> IRuleUsageTracker.GetTotalByAppAsync(DomainId appId,
CancellationToken ct)
{
var result = new Dictionary<DomainId, RuleCounters>();
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<IReadOnlyList<RuleStats>> QueryForRulesAsync(string key, DateOnly fromDate, DateOnly toDate,
CancellationToken ct)
{
var result = new List<RuleStats>();
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<Task>
{
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";
}
}

125
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<long> GetTotalSizeByAppAsync(DomainId appId,
CancellationToken ct = default)
{
return GetTotalSizeAsync(AppAssetsKey(appId), ct);
}
public Task<long> GetTotalSizeByTeamAsync(DomainId teamId,
CancellationToken ct = default)
{
return GetTotalSizeAsync(TeamAssetsKey(teamId), ct);
}
private async Task<long> GetTotalSizeAsync(string key,
CancellationToken ct)
{
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct);
return counters.GetInt64(CounterTotalSize);
}
public Task<IReadOnlyList<AssetStats>> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate,
CancellationToken ct = default)
{
return QueryAsync(AppAssetsKey(appId), fromDate, toDate, ct);
}
public Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate,
CancellationToken ct = default)
{
return QueryAsync(TeamAssetsKey(teamId), fromDate, toDate, ct);
}
private async Task<IReadOnlyList<AssetStats>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
CancellationToken ct)
{
var enriched = new List<AssetStats>();
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<AssetStats> 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<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date,
public async Task<bool> 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<Task>
{
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";

11
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @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);
}
}

124
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @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<EnrichedEvent?> CreateEnrichedEventsAsync(Envelope<AppEvent> @event,
@ -87,7 +127,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri
return await CreateEnrichedEventsCoreAsync(@event, ct);
}
private async ValueTask<EnrichedEvent> CreateEnrichedEventsCoreAsync(Envelope<AppEvent> @event,
private async ValueTask<EnrichedContentEvent> CreateEnrichedEventsCoreAsync(Envelope<AppEvent> @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<AppEvent> @event, RuleContext context)
public bool Trigger(Envelope<AppEvent> @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<SchemaCondition>? 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<SchemaCondition>? 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<DomainId> schemaId)
private static bool MatchsSchema(SchemaCondition? schema, NamedId<DomainId> 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))
{

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

@ -19,6 +19,9 @@ public interface IContentRepository
IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
CancellationToken ct = default);
IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId references, int take,
CancellationToken ct = default);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope,
CancellationToken ct = default);
@ -34,7 +37,7 @@ public interface IContentRepository
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope,
CancellationToken ct = default);
Task<bool> HasReferrersAsync(DomainId appId, DomainId contentId, SearchScope scope,
Task<bool> HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope,
CancellationToken ct = default);
Task ResetScheduledAsync(DomainId documentId,

2
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs

@ -93,7 +93,7 @@ public sealed class RuleTriggerValidator : IRuleTriggerVisitor<Task<IEnumerable<
return errors;
}
private async Task<ValidationError?> CheckSchemaAsync(ContentChangedTriggerSchemaV2 schema)
private async Task<ValidationError?> CheckSchemaAsync(SchemaCondition schema)
{
if (await SchemaProvider(schema.SchemaId) == null)
{

2
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs

@ -117,7 +117,7 @@ public partial class RuleDomainObject : DomainObject<RuleDomainObject.State>
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()

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

4
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<IEvent> @event);
}
Task EnqueueAsync(DomainId ruleId, Rule rule, Envelope<IEvent> @event);
}

35
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<IReadOnlyList<RuleStats>> QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
Task<IReadOnlyList<RuleStats>> QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
Task<IReadOnlyDictionary<DomainId, RuleCounters>> 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);

2
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RulesContext context,
[EnumeratorCancellation] CancellationToken ct)
{
var result = new EnrichedManualEvent();

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

32
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<RuleEventWrite> jobs,
CancellationToken ct = default);
Task EnqueueAsync(DomainId id, Instant nextAttempt,
@ -56,9 +39,6 @@ public interface IRuleEventRepository
Task QueryPendingAsync(Instant now, Func<IRuleEventEntity, Task> callback,
CancellationToken ct = default);
Task<IReadOnlyList<RuleStatistics>> QueryStatisticsByAppAsync(DomainId appId,
CancellationToken ct = default);
Task<IResultList<IRuleEventEntity>> QueryByAppAsync(DomainId appId, DomainId? ruleId = null, int skip = 0, int take = 20,
CancellationToken ct = default);

24
backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs

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

9
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs

@ -25,6 +25,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
private readonly ITargetBlock<IRuleEventEntity> requestBlock;
private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleService ruleService;
private readonly IRuleUsageTracker ruleUsageTracker;
private readonly ILogger<RuleDequeuerWorker> log;
private CompletionTimer timer;
@ -32,11 +33,13 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
public RuleDequeuerWorker(
IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
IRuleEventRepository ruleEventRepository,
ILogger<RuleDequeuerWorker> 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)
{

79
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<RuleEnqueuer> 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<RuleOptions> options,
ILogger<RuleEnqueuer> 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<IEvent> @event)
public async Task EnqueueAsync(DomainId ruleId, Rule rule, Envelope<IEvent> @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<DomainId, Rule>
{
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<IEvent> @event)
public async Task On(IEnumerable<Envelope<IEvent>> 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);
}
}
}

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

90
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<RuleEventWrite> writes = new List<RuleEventWrite>();
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<bool> 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<bool> 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();
}
}

76
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<DomainId, Rule>
{
[ruleId] = rule
}.ToReadonlyDictionary()
};
var simulatedEvents = new List<SimulatedRuleEvent>(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
};
}
}

74
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<RuleRunnerProcessor> log;
private readonly SimpleState<RuleRunnerState> state;
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1);
@ -78,6 +79,7 @@ public sealed class RuleRunnerProcessor
IPersistenceFactory<RuleRunnerState> persistenceFactory,
IRuleEventRepository ruleEventRepository,
IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
ILogger<RuleRunnerProcessor> 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<RuleRunnerState>(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);
}
}

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

38
backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerWorker.cs

@ -89,27 +89,33 @@ public sealed class UsageTrackerWorker : IMessageHandler<UsageTrackingMessage>,
{
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<IEvent>(@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<IEvent>(@event));
}
await state.WriteAsync();

9
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @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<AppEvent> @event, RuleContext context)
public bool Trigger(Envelope<AppEvent> @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;
}
}

11
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @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);
}
}

17
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<EventData> events,
public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct = default)
{
return AppendEventsInternalAsync(streamName, EtagVersion.Any, events, ct);
}
public Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct = default)
{
Guard.GreaterEquals(expectedVersion, -1);
return AppendEventsInternalAsync(streamName, expectedVersion, events, ct);
}
private async Task AppendEventsInternalAsync(string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct)
{
Guard.NotNullOrEmpty(streamName);
Guard.NotNull(events);
Guard.GreaterEquals(expectedVersion, EtagVersion.Any);
using (Telemetry.Activities.StartActivity("GetEventStore/AppendEventsInternalAsync"))
{

6
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<EventData> events,
CancellationToken ct = default)
{
return AppendAsync(commitId, streamName, EtagVersion.Any, events, ct);
}
public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct = default)
{

18
backend/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs

@ -75,7 +75,7 @@ public sealed class MongoUsageRepository : MongoRepositoryBase<MongoUsage>, IUsa
}
else if (updates.Length > 0)
{
var writes = new List<WriteModel<MongoUsage>>();
var writes = new List<WriteModel<MongoUsage>>(updates.Length);
foreach (var update in updates)
{
@ -87,7 +87,10 @@ public sealed class MongoUsageRepository : MongoRepositoryBase<MongoUsage>, 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<MongoUsage>, 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<MongoUsage>, IUsa
return (filter, update);
}
public async Task<IReadOnlyList<StoredUsage>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
public async Task<IReadOnlyList<StoredUsage>> 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();
}
}

49
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<TSource> Catch<TSource>(this IAsyncEnumerable<TSource> source, Func<Exception, IEnumerable<TSource>> handler)
{
return Core(source, handler);
static async IAsyncEnumerable<TSource> Core(IAsyncEnumerable<TSource> source, Func<Exception, IEnumerable<TSource>> handler,
[EnumeratorCancellation] CancellationToken ct = default)
{
var error = default(IEnumerable<TSource>);
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;
}
}
}
}
}

3
backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs

@ -23,9 +23,6 @@ public interface IEventStore
IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, int take = int.MaxValue,
CancellationToken ct = default);
Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events,
CancellationToken ct = default);
Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct = default);

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

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

2
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);

10
backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs

@ -27,7 +27,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker
return usageTracker.DeleteAsync(apiKey, ct);
}
public async Task<long> GetMonthCallsAsync(string key, DateTime date, string? category,
public async Task<long> 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<long> GetMonthBytesAsync(string key, DateTime date, string? category,
public async Task<long> 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<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate,
public async Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> 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,

20
backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs

@ -17,7 +17,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
private readonly IUsageRepository usageRepository;
private readonly ILogger<BackgroundUsageTracker> 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<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
public async Task<Dictionary<string, List<(DateOnly, Counters)>>> QueryAsync(string key, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(key);
ThrowIfDisposed();
var result = new Dictionary<string, List<(DateTime Date, Counters Counters)>>();
var result = new Dictionary<string, List<(DateOnly Date, Counters Counters)>>();
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<Counters> GetForMonthAsync(string key, DateTime date, string? category,
public Task<Counters> 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<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate, string? category,
public async Task<Counters> GetAsync(string key, DateOnly fromDate, DateOnly toDate, string? category,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(key);

8
backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -39,7 +39,7 @@ public sealed class CachingUsageTracker : IUsageTracker
return inner.DeleteByKeyPatternAsync(pattern, ct);
}
public Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
public Task<Dictionary<string, List<(DateOnly, Counters)>>> 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<Counters> GetForMonthAsync(string key, DateTime date, string? category,
public Task<Counters> GetForMonthAsync(string key, DateOnly date, string? category,
CancellationToken ct = default)
{
Guard.NotNull(key);
@ -70,7 +70,7 @@ public sealed class CachingUsageTracker : IUsageTracker
})!;
}
public Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate, string? category,
public Task<Counters> GetAsync(string key, DateOnly fromDate, DateOnly toDate, string? category,
CancellationToken ct = default)
{
Guard.NotNull(key);

8
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<long> GetMonthCallsAsync(string key, DateTime date, string? category,
Task<long> GetMonthCallsAsync(string key, DateOnly date, string? category,
CancellationToken ct = default);
Task<long> GetMonthBytesAsync(string key, DateTime date, string? category,
Task<long> GetMonthBytesAsync(string key, DateOnly date, string? category,
CancellationToken ct = default);
Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate,
Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
}

2
backend/src/Squidex.Infrastructure/UsageTracking/IUsageRepository.cs

@ -15,7 +15,7 @@ public interface IUsageRepository
Task TrackUsagesAsync(UsageUpdate[] updates,
CancellationToken ct = default);
Task<IReadOnlyList<StoredUsage>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
Task<IReadOnlyList<StoredUsage>> QueryAsync(string key, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
Task DeleteAsync(string key,

8
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<Counters> GetForMonthAsync(string key, DateTime date, string? category,
Task<Counters> GetForMonthAsync(string key, DateOnly date, string? category,
CancellationToken ct = default);
Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate, string? category,
Task<Counters> GetAsync(string key, DateOnly fromDate, DateOnly toDate, string? category,
CancellationToken ct = default);
Task<Dictionary<string, List<(DateTime, Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
Task<Dictionary<string, List<(DateOnly, Counters)>>> QueryAsync(string key, DateOnly fromDate, DateOnly toDate,
CancellationToken ct = default);
Task DeleteAsync(string key,

2
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);

4
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;

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

1
backend/src/Squidex.Web/Pipeline/IgnoreCacheFilterAttribute.cs

@ -7,6 +7,7 @@
namespace Squidex.Web.Pipeline;
[AttributeUsage(AttributeTargets.All)]
public sealed class IgnoreCacheFilterAttribute : Attribute
{
}

7
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,

1
backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs

@ -94,6 +94,7 @@ public static class OpenApiServices
CreateArrayMap<FieldNames>(JsonObjectType.String),
CreateObjectMap<AssetMetadata>(),
CreateObjectMap<JsonObject>(),
CreateStringMap<DateOnly>(JsonFormatStrings.Date),
CreateStringMap<DomainId>(),
CreateStringMap<Instant>(JsonFormatStrings.DateTime),
CreateStringMap<Language>(),

2
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)
{

15
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<RuleTriggerDto>
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);
}
}

5
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs

@ -76,16 +76,17 @@ public sealed class RuleDto : Resource
/// <summary>
/// The number of completed executions.
/// </summary>
public int NumSucceeded { get; set; }
public long NumSucceeded { get; set; }
/// <summary>
/// The number of failed executions.
/// </summary>
public int NumFailed { get; set; }
public long NumFailed { get; set; }
/// <summary>
/// The date and time when the rule was executed the last time.
/// </summary>
[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)

6
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs

@ -20,6 +20,12 @@ public sealed record SimulatedRuleEventDto
[Required]
public Guid EventId { get; init; }
/// <summary>
/// The the unique id of the simulated event.
/// </summary>
[Required]
public string UniqueId { get; set; }
/// <summary>
/// The name of the event.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedRuleTriggerDto.cs

@ -18,6 +18,11 @@ public sealed class AssetChangedRuleTriggerDto : RuleTriggerDto
/// </summary>
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());

5
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CommentRuleTriggerDto.cs

@ -18,6 +18,11 @@ public class CommentRuleTriggerDto : RuleTriggerDto
/// </summary>
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());

17
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
/// <summary>
/// The schema settings.
/// </summary>
public ContentChangedRuleTriggerSchemaDto[]? Schemas { get; set; }
public ReadonlyList<SchemaCondition>? Schemas { get; set; }
/// <summary>
/// The schema references.
/// </summary>
public ReadonlyList<SchemaCondition>? ReferencedSchemas { get; set; }
/// <summary>
/// Determines whether the trigger should handle all content changes events.
/// </summary>
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());
}
}

37
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs

@ -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
{
/// <summary>
/// The ID of the schema.
/// </summary>
public DomainId SchemaId { get; set; }
/// <summary>
/// Javascript condition when to trigger.
/// </summary>
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;
}
}

5
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());

5
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/SchemaChangedRuleTriggerDto.cs

@ -18,6 +18,11 @@ public sealed class SchemaChangedRuleTriggerDto : RuleTriggerDto
/// </summary>
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());

5
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());

57
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<FieldP
{
}
public static FieldPropertiesDto Create(FieldProperties properties)
public static FieldPropertiesDto Create(FieldProperties fieldProperties)
{
return properties.Accept(Instance, None.Value);
return fieldProperties.Accept(Instance, None.Value);
}
public FieldPropertiesDto Visit(ArrayFieldProperties properties, None args)
public FieldPropertiesDto Visit(ArrayFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new ArrayFieldPropertiesDto());
return ArrayFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(AssetsFieldProperties properties, None args)
public FieldPropertiesDto Visit(AssetsFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new AssetsFieldPropertiesDto());
return AssetsFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(BooleanFieldProperties properties, None args)
public FieldPropertiesDto Visit(BooleanFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new BooleanFieldPropertiesDto());
return BooleanFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(ComponentFieldProperties properties, None args)
public FieldPropertiesDto Visit(ComponentFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new ComponentFieldPropertiesDto());
return ComponentFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(ComponentsFieldProperties properties, None args)
public FieldPropertiesDto Visit(ComponentsFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new ComponentsFieldPropertiesDto());
return ComponentsFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(DateTimeFieldProperties properties, None args)
public FieldPropertiesDto Visit(DateTimeFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new DateTimeFieldPropertiesDto());
return DateTimeFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(GeolocationFieldProperties properties, None args)
public FieldPropertiesDto Visit(GeolocationFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new GeolocationFieldPropertiesDto());
return GeolocationFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(JsonFieldProperties properties, None args)
public FieldPropertiesDto Visit(JsonFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new JsonFieldPropertiesDto());
return JsonFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(NumberFieldProperties properties, None args)
public FieldPropertiesDto Visit(NumberFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new NumberFieldPropertiesDto());
return NumberFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(ReferencesFieldProperties properties, None args)
public FieldPropertiesDto Visit(ReferencesFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new ReferencesFieldPropertiesDto());
return ReferencesFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(StringFieldProperties properties, None args)
public FieldPropertiesDto Visit(StringFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new StringFieldPropertiesDto());
return StringFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(TagsFieldProperties properties, None args)
public FieldPropertiesDto Visit(TagsFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new TagsFieldPropertiesDto());
return TagsFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(UIFieldProperties properties, None args)
public FieldPropertiesDto Visit(UIFieldProperties fieldProperties, None args)
{
return SimpleMapper.Map(properties, new UIFieldPropertiesDto());
return UIFieldPropertiesDto.FromDomain(fieldProperties);
}
}

9
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs

@ -28,10 +28,13 @@ public sealed class ArrayFieldPropertiesDto : FieldPropertiesDto
/// </summary>
public ReadonlyList<string>? 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());
}
}

9
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs

@ -124,10 +124,13 @@ public sealed class AssetsFieldPropertiesDto : FieldPropertiesDto
/// </summary>
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());
}
}

9
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/BooleanFieldPropertiesDto.cs

@ -32,10 +32,13 @@ public sealed class BooleanFieldPropertiesDto : FieldPropertiesDto
/// </summary>
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());
}
}

9
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentFieldPropertiesDto.cs

@ -19,10 +19,13 @@ public sealed class ComponentFieldPropertiesDto : FieldPropertiesDto
/// </summary>
public ReadonlyList<DomainId>? 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());
}
}

9
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ComponentsFieldPropertiesDto.cs

@ -34,10 +34,13 @@ public sealed class ComponentsFieldPropertiesDto : FieldPropertiesDto
/// </summary>
public ReadonlyList<string>? 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());
}
}

9
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/DateTimeFieldPropertiesDto.cs

@ -48,10 +48,13 @@ public sealed class DateTimeFieldPropertiesDto : FieldPropertiesDto
/// </summary>
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());
}
}

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

Loading…
Cancel
Save