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.", "roles.updateFailed": "Failed to update role. Please reload.",
"rules.actionData": "Action Data", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.", "rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.addSchema": "Add Schema",
"rules.advancedFormattingHint": "You can use advanced formatting", "rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Failed to cancel rule. Please reload.", "rules.cancelFailed": "Failed to cancel rule. Please reload.",
"rules.condition": "Condition",
"rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint": "Optional condition as javascript expression",
"rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example",
"rules.conditions.commentKeyword": "Only for text keywords", "rules.conditions.commentKeyword": "Only for text keywords",
@ -703,6 +703,8 @@
"rules.createTooltip": "New Rule", "rules.createTooltip": "New Rule",
"rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmText": "Do you really want to delete the rule?",
"rules.deleteConfirmTitle": "Delete 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.deleteFailed": "Failed to delete rule. Please reload.",
"rules.empty": "No rule created yet.", "rules.empty": "No rule created yet.",
"rules.emptyAddRule": "Add Rule", "rules.emptyAddRule": "Add Rule",
@ -712,6 +714,8 @@
"rules.listPageTitle": "Rules", "rules.listPageTitle": "Rules",
"rules.loadFailed": "Failed to load Rules. Please reload.", "rules.loadFailed": "Failed to load Rules. Please reload.",
"rules.readMore": "Read More", "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.refreshEventsTooltip": "Refresh Events",
"rules.refreshTooltip": "Refresh Rules", "rules.refreshTooltip": "Refresh Rules",
"rules.reloaded": "Rules reloaded.", "rules.reloaded": "Rules reloaded.",
@ -739,6 +743,7 @@
"rules.runningRule": "Rule '{name}' is currently running.", "rules.runningRule": "Rule '{name}' is currently running.",
"rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?",
"rules.runRuleConfirmTitle": "Run rule", "rules.runRuleConfirmTitle": "Run rule",
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simulate", "rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.", "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.", "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.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Unnamed Rule", "rules.unnamed": "Unnamed Rule",
"rules.updateFailed": "Failed to update rule. Please reload.", "rules.updateFailed": "Failed to update rule. Please reload.",
"rules.when": "When",
"schemas.addField": "Add Field", "schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndClose": "Create and close",
"schemas.addFieldAndCreate": "Create and add field", "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.", "roles.updateFailed": "Non è stato possibile aggiornare il ruolo. Per favore ricarica.",
"rules.actionData": "Action Data", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.", "rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.addSchema": "Add Schema",
"rules.advancedFormattingHint": "You can use advanced formatting", "rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", "rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.",
"rules.condition": "Condition",
"rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint": "Optional condition as javascript expression",
"rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example",
"rules.conditions.commentKeyword": "Only for text keywords", "rules.conditions.commentKeyword": "Only for text keywords",
@ -703,6 +703,8 @@
"rules.createTooltip": "Nuova regola", "rules.createTooltip": "Nuova regola",
"rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?", "rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?",
"rules.deleteConfirmTitle": "Cancella 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.deleteFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.",
"rules.empty": "Nessuna regola è stato ancora creata.", "rules.empty": "Nessuna regola è stato ancora creata.",
"rules.emptyAddRule": "Aggiungi una regola", "rules.emptyAddRule": "Aggiungi una regola",
@ -712,6 +714,8 @@
"rules.listPageTitle": "Regole", "rules.listPageTitle": "Regole",
"rules.loadFailed": "Non è stato possibile caricare le regole. Per favore ricarica.", "rules.loadFailed": "Non è stato possibile caricare le regole. Per favore ricarica.",
"rules.readMore": "Leggi di più", "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.refreshEventsTooltip": "Aggiorna gli Eventi",
"rules.refreshTooltip": "Aggiorna le Regole", "rules.refreshTooltip": "Aggiorna le Regole",
"rules.reloaded": "Regole ricaricate.", "rules.reloaded": "Regole ricaricate.",
@ -739,6 +743,7 @@
"rules.runningRule": "La regola '{name}' è attualmente in esecuzione.", "rules.runningRule": "La regola '{name}' è attualmente in esecuzione.",
"rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?", "rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?",
"rules.runRuleConfirmTitle": "Esegui la regola", "rules.runRuleConfirmTitle": "Esegui la regola",
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simulate", "rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.", "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.", "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.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Regola senza nome", "rules.unnamed": "Regola senza nome",
"rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.", "rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.",
"rules.when": "When",
"schemas.addField": "Aggiungi un Campo", "schemas.addField": "Aggiungi un Campo",
"schemas.addFieldAndClose": "Crea e chiudi", "schemas.addFieldAndClose": "Crea e chiudi",
"schemas.addFieldAndCreate": "Crea e aggiungi il campo", "schemas.addFieldAndCreate": "Crea e aggiungi il campo",

8
backend/i18n/frontend_nl.json

@ -681,9 +681,9 @@
"roles.updateFailed": "Update rol mislukt. Laad opnieuw.", "roles.updateFailed": "Update rol mislukt. Laad opnieuw.",
"rules.actionData": "Actiegegevens", "rules.actionData": "Actiegegevens",
"rules.actionHint": "De selectie van het actietype kan later niet worden gewijzigd.", "rules.actionHint": "De selectie van het actietype kan later niet worden gewijzigd.",
"rules.addSchema": "Add Schema",
"rules.advancedFormattingHint": "You can use advanced formatting", "rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.", "rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.",
"rules.condition": "Condition",
"rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint": "Optional condition as javascript expression",
"rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example",
"rules.conditions.commentKeyword": "Only for text keywords", "rules.conditions.commentKeyword": "Only for text keywords",
@ -703,6 +703,8 @@
"rules.createTooltip": "Nieuwe regel", "rules.createTooltip": "Nieuwe regel",
"rules.deleteConfirmText": "Wil je de regel echt verwijderen?", "rules.deleteConfirmText": "Wil je de regel echt verwijderen?",
"rules.deleteConfirmTitle": "Regel 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.deleteFailed": "Verwijderen van regel is mislukt. Laad opnieuw.",
"rules.empty": "Nog geen regel aangemaakt.", "rules.empty": "Nog geen regel aangemaakt.",
"rules.emptyAddRule": "Regel toevoegen", "rules.emptyAddRule": "Regel toevoegen",
@ -712,6 +714,8 @@
"rules.listPageTitle": "Regels", "rules.listPageTitle": "Regels",
"rules.loadFailed": "Laden van regels is mislukt. Laad opnieuw.", "rules.loadFailed": "Laden van regels is mislukt. Laad opnieuw.",
"rules.readMore": "Lees meer", "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.refreshEventsTooltip": "Ververs evenementen",
"rules.refreshTooltip": "Vernieuwingsregels", "rules.refreshTooltip": "Vernieuwingsregels",
"rules.reloaded": "Regels herladen.", "rules.reloaded": "Regels herladen.",
@ -739,6 +743,7 @@
"rules.runningRule": "Regel '{name}' is momenteel actief.", "rules.runningRule": "Regel '{name}' is momenteel actief.",
"rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?", "rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?",
"rules.runRuleConfirmTitle": "Regel uitvoeren", "rules.runRuleConfirmTitle": "Regel uitvoeren",
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simuleren", "rules.simulate": "Simuleren",
"rules.simulateTooltip": "Simuleer deze regels met behulp van de laatste 100 gebeurtenissen.", "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.", "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.triggerHint": "De selectie van het triggertype kan later niet worden gewijzigd.",
"rules.unnamed": "Naamloos regel", "rules.unnamed": "Naamloos regel",
"rules.updateFailed": "Update regel mislukt. Laad opnieuw.", "rules.updateFailed": "Update regel mislukt. Laad opnieuw.",
"rules.when": "When",
"schemas.addField": "Veld toevoegen", "schemas.addField": "Veld toevoegen",
"schemas.addFieldAndClose": "Maken en sluiten", "schemas.addFieldAndClose": "Maken en sluiten",
"schemas.addFieldAndCreate": "Maak en voeg veld toe", "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.", "roles.updateFailed": "Falhou na atualização do grupo. Por favor, recarregue.",
"rules.actionData": "Dados de Ação", "rules.actionData": "Dados de Ação",
"rules.actionHint": "A seleção do tipo de ação não pode ser alterada mais tarde.", "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.advancedFormattingHint": "Você pode usar formatação avançada",
"rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.", "rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.",
"rules.condition": "Condition",
"rules.conditionHint": "Condição opcional como expressão javascript", "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.conditionHint2": "As condições são expressões javascript que definem quando desencadear, por exemplo",
"rules.conditions.commentKeyword": "Apenas para palavras-chave de texto", "rules.conditions.commentKeyword": "Apenas para palavras-chave de texto",
@ -703,6 +703,8 @@
"rules.createTooltip": "Nova Regra", "rules.createTooltip": "Nova Regra",
"rules.deleteConfirmText": "Quer mesmo apagar a regra?", "rules.deleteConfirmText": "Quer mesmo apagar a regra?",
"rules.deleteConfirmTitle": "Eliminar 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.deleteFailed": "Falhou em apagar a regra. Por favor, recarregue.",
"rules.empty": "Nenhuma regra criada ainda.", "rules.empty": "Nenhuma regra criada ainda.",
"rules.emptyAddRule": "Adicionar Regra", "rules.emptyAddRule": "Adicionar Regra",
@ -712,6 +714,8 @@
"rules.listPageTitle": "Regras", "rules.listPageTitle": "Regras",
"rules.loadFailed": "Falhou em carregar as regras. Por favor, recarregue.", "rules.loadFailed": "Falhou em carregar as regras. Por favor, recarregue.",
"rules.readMore": "Ler Mais", "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.refreshEventsTooltip": "Atualizar eventos",
"rules.refreshTooltip": "Atualizar regras", "rules.refreshTooltip": "Atualizar regras",
"rules.reloaded": "Regras recarregadas.", "rules.reloaded": "Regras recarregadas.",
@ -739,6 +743,7 @@
"rules.runningRule": "A regra '{name}' está atualmente em execução.", "rules.runningRule": "A regra '{name}' está atualmente em execução.",
"rules.runRuleConfirmText": "Quer mesmo gerir a regra para todos os eventos?", "rules.runRuleConfirmText": "Quer mesmo gerir a regra para todos os eventos?",
"rules.runRuleConfirmTitle": "Regra de execução", "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.simulate": "Simular",
"rules.simulateTooltip": "Simular estas regras usando os últimos 100 eventos.", "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.", "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.triggerHint": "A seleção do tipo de gatilho não pode ser alterada mais tarde.",
"rules.unnamed": "Regra sem nome", "rules.unnamed": "Regra sem nome",
"rules.updateFailed": "Falhou na atualização da regra. Por favor, recarregue.", "rules.updateFailed": "Falhou na atualização da regra. Por favor, recarregue.",
"rules.when": "When",
"schemas.addField": "Adicionar Campo", "schemas.addField": "Adicionar Campo",
"schemas.addFieldAndClose": "Criar e fechar", "schemas.addFieldAndClose": "Criar e fechar",
"schemas.addFieldAndCreate": "Criar e adicionar campo", "schemas.addFieldAndCreate": "Criar e adicionar campo",

8
backend/i18n/frontend_zh.json

@ -681,9 +681,9 @@
"roles.updateFailed": "更新角色失败。请重新加载。", "roles.updateFailed": "更新角色失败。请重新加载。",
"rules.actionData": "动作数据", "rules.actionData": "动作数据",
"rules.actionHint": "动作类型的选择以后不能更改。", "rules.actionHint": "动作类型的选择以后不能更改。",
"rules.addSchema": "Add Schema",
"rules.advancedFormattingHint": "You can use advanced formatting", "rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "取消规则失败,请重新加载。", "rules.cancelFailed": "取消规则失败,请重新加载。",
"rules.condition": "Condition",
"rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint": "Optional condition as javascript expression",
"rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example",
"rules.conditions.commentKeyword": "Only for text keywords", "rules.conditions.commentKeyword": "Only for text keywords",
@ -703,6 +703,8 @@
"rules.createTooltip": "新规则", "rules.createTooltip": "新规则",
"rules.deleteConfirmText": "你真的要删除规则吗?", "rules.deleteConfirmText": "你真的要删除规则吗?",
"rules.deleteConfirmTitle": "删除规则", "rules.deleteConfirmTitle": "删除规则",
"rules.deleteContentChangedSchemaText": "Do you really want to remove the schema?",
"rules.deleteContentChangedSchemaTitle": "Remove schema",
"rules.deleteFailed": "删除规则失败,请重新加载。", "rules.deleteFailed": "删除规则失败,请重新加载。",
"rules.empty": "尚未创建规则。", "rules.empty": "尚未创建规则。",
"rules.emptyAddRule": "添加规则", "rules.emptyAddRule": "添加规则",
@ -712,6 +714,8 @@
"rules.listPageTitle": "规则", "rules.listPageTitle": "规则",
"rules.loadFailed": "加载规则失败。请重新加载。", "rules.loadFailed": "加载规则失败。请重新加载。",
"rules.readMore": "阅读更多", "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.refreshEventsTooltip": "刷新事件",
"rules.refreshTooltip": "刷新规则", "rules.refreshTooltip": "刷新规则",
"rules.reloaded": "规则重新加载。", "rules.reloaded": "规则重新加载。",
@ -739,6 +743,7 @@
"rules.runningRule": "规则 '{name}' 当前正在运行。", "rules.runningRule": "规则 '{name}' 当前正在运行。",
"rules.runRuleConfirmText": "你真的想为所有事件运行规则吗?", "rules.runRuleConfirmText": "你真的想为所有事件运行规则吗?",
"rules.runRuleConfirmTitle": "运行规则", "rules.runRuleConfirmTitle": "运行规则",
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "模拟", "rules.simulate": "模拟",
"rules.simulateTooltip": "使用最近 100 个事件模拟此规则。", "rules.simulateTooltip": "使用最近 100 个事件模拟此规则。",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.", "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.triggerHint": "以后不能更改触发器类型的选择。",
"rules.unnamed": "未命名规则", "rules.unnamed": "未命名规则",
"rules.updateFailed": "更新规则失败。请重新加载。", "rules.updateFailed": "更新规则失败。请重新加载。",
"rules.when": "When",
"schemas.addField": "添加字段", "schemas.addField": "添加字段",
"schemas.addFieldAndClose": "创建并关闭", "schemas.addFieldAndClose": "创建并关闭",
"schemas.addFieldAndCreate": "创建并添加字段", "schemas.addFieldAndCreate": "创建并添加字段",

8
backend/i18n/source/frontend_en.json

@ -681,9 +681,9 @@
"roles.updateFailed": "Failed to update role. Please reload.", "roles.updateFailed": "Failed to update role. Please reload.",
"rules.actionData": "Action Data", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.", "rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.addSchema": "Add Schema",
"rules.advancedFormattingHint": "You can use advanced formatting", "rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Failed to cancel rule. Please reload.", "rules.cancelFailed": "Failed to cancel rule. Please reload.",
"rules.condition": "Condition",
"rules.conditionHint": "Optional condition as javascript expression", "rules.conditionHint": "Optional condition as javascript expression",
"rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example", "rules.conditionHint2": "Conditions are javascript expressions that define when to trigger, for example",
"rules.conditions.commentKeyword": "Only for text keywords", "rules.conditions.commentKeyword": "Only for text keywords",
@ -703,6 +703,8 @@
"rules.createTooltip": "New Rule", "rules.createTooltip": "New Rule",
"rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmText": "Do you really want to delete the rule?",
"rules.deleteConfirmTitle": "Delete 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.deleteFailed": "Failed to delete rule. Please reload.",
"rules.empty": "No rule created yet.", "rules.empty": "No rule created yet.",
"rules.emptyAddRule": "Add Rule", "rules.emptyAddRule": "Add Rule",
@ -712,6 +714,8 @@
"rules.listPageTitle": "Rules", "rules.listPageTitle": "Rules",
"rules.loadFailed": "Failed to load Rules. Please reload.", "rules.loadFailed": "Failed to load Rules. Please reload.",
"rules.readMore": "Read More", "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.refreshEventsTooltip": "Refresh Events",
"rules.refreshTooltip": "Refresh Rules", "rules.refreshTooltip": "Refresh Rules",
"rules.reloaded": "Rules reloaded.", "rules.reloaded": "Rules reloaded.",
@ -739,6 +743,7 @@
"rules.runningRule": "Rule '{name}' is currently running.", "rules.runningRule": "Rule '{name}' is currently running.",
"rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?",
"rules.runRuleConfirmTitle": "Run rule", "rules.runRuleConfirmTitle": "Run rule",
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simulate", "rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.", "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.", "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.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Unnamed Rule", "rules.unnamed": "Unnamed Rule",
"rules.updateFailed": "Failed to update rule. Please reload.", "rules.updateFailed": "Failed to update rule. Please reload.",
"rules.when": "When",
"schemas.addField": "Add Field", "schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndClose": "Create and close",
"schemas.addFieldAndCreate": "Create and add field", "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.loadPermissionsFailed": "Non è stato possibile caricare i permessi. Per favore ricarica.",
"roles.permissions": "Permessi", "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.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": "Proprietà",
"roles.properties.hideAPI": "Nascondi le API", "roles.properties.hideAPI": "Nascondi le API",
"roles.properties.hideAssets": "Nascondi le Risorse", "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.loadPermissionsFailed": "Kan machtigingen niet laden. Laad opnieuw.",
"roles.permissions": "Rechten", "roles.permissions": "Rechten",
"roles.permissionsDescription": "Machtigingen beperken de toegestane bewerkingen en zoekopdrachten op API-niveau en zijn een beveiligingsfunctie.", "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": "Eigenschappen",
"roles.properties.hideAPI": "API verbergen", "roles.properties.hideAPI": "API verbergen",
"roles.properties.hideAssets": "Assets 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.loadPermissionsFailed": "Falhou em carregar permissões. Por favor, recarregue.",
"roles.permissions": "Permissões", "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.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": "Propriedades",
"roles.properties.hideAPI": "Ocultar API", "roles.properties.hideAPI": "Ocultar API",
"roles.properties.hideAssets": "Ocultar ficheiros", "roles.properties.hideAssets": "Ocultar ficheiros",
@ -669,7 +668,6 @@
"roles.updateFailed": "Falhou na atualização do grupo. Por favor, recarregue.", "roles.updateFailed": "Falhou na atualização do grupo. Por favor, recarregue.",
"rules.actionData": "Dados de Ação", "rules.actionData": "Dados de Ação",
"rules.actionHint": "A seleção do tipo de ação não pode ser alterada mais tarde.", "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.advancedFormattingHint": "Você pode usar formatação avançada",
"rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.", "rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.",
"rules.conditionHint": "Condição opcional como expressão javascript", "rules.conditionHint": "Condição opcional como expressão javascript",

1
backend/i18n/source/frontend_zh.json

@ -563,7 +563,6 @@
"roles.loadPermissionsFailed": "加载权限失败。请重新加载。", "roles.loadPermissionsFailed": "加载权限失败。请重新加载。",
"roles.permissions": "权限", "roles.permissions": "权限",
"roles.permissionsDescription": "权限在 API 级别限制允许的操作和查询,是一项安全功能。", "roles.permissionsDescription": "权限在 API 级别限制允许的操作和查询,是一项安全功能。",
"roles.permissionsPlaceholder": "开始输入以搜索权限",
"roles.properties": "属性", "roles.properties": "属性",
"roles.properties.hideAPI": "隐藏 API", "roles.properties.hideAPI": "隐藏 API",
"roles.properties.hideAssets": "隐藏资源", "roles.properties.hideAssets": "隐藏资源",

4
backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs

@ -29,7 +29,7 @@ public sealed class ContentChangedTriggerSchema
public bool SendRestore { get; set; } public bool SendRestore { get; set; }
public ContentChangedTriggerSchemaV2 Migrate() public SchemaCondition Migrate()
{ {
var conditions = new List<string>(); var conditions = new List<string>();
@ -71,6 +71,6 @@ public sealed class ContentChangedTriggerSchema
var schemaId = DomainId.Create(SchemaId); 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, Published,
StatusChanged, StatusChanged,
Updated, 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))] [TypeName(nameof(ContentChangedTriggerV2))]
public sealed record ContentChangedTriggerV2 : RuleTrigger 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; } 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; namespace Squidex.Domain.Apps.Core.Rules.Triggers;
public sealed record ContentChangedTriggerSchemaV2 public record class SchemaCondition
{ {
public DomainId SchemaId { get; init; } 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -12,14 +13,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules;
public interface IRuleService public interface IRuleService
{ {
bool CanCreateSnapshotEvents(RuleContext context); bool CanCreateSnapshotEvents(Rule rule);
string GetName(AppEvent @event); string GetName(AppEvent @event);
IAsyncEnumerable<JobResult> CreateSnapshotJobsAsync(RuleContext context, IAsyncEnumerable<JobResult> CreateSnapshotJobsAsync(RuleContext context,
CancellationToken ct = default); CancellationToken ct = default);
IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RuleContext context, IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RulesContext context,
CancellationToken ct = default); CancellationToken ct = default);
Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job, 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. // 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.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -26,7 +27,7 @@ public interface IRuleTriggerHandler
return AsyncEnumerable.Empty<EnrichedEvent>(); return AsyncEnumerable.Empty<EnrichedEvent>();
} }
IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context, IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RulesContext context,
CancellationToken ct); CancellationToken ct);
string? GetName(AppEvent @event) string? GetName(AppEvent @event)
@ -34,12 +35,12 @@ public interface IRuleTriggerHandler
return null; return null;
} }
bool Trigger(Envelope<AppEvent> @event, RuleContext context) bool Trigger(Envelope<AppEvent> @event, RuleTrigger trigger)
{ {
return true; return true;
} }
bool Trigger(EnrichedEvent @event, RuleContext context) bool Trigger(EnrichedEvent @event, RuleTrigger trigger)
{ {
return true; 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;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules; namespace Squidex.Domain.Apps.Core.HandleRules;
public sealed record JobResult public sealed record JobResult
{ {
public static readonly JobResult ConditionDoesNotMatch = new JobResult
{
SkipReason = SkipReason.ConditionDoesNotMatch
};
public static readonly JobResult ConditionPrecheckDoesNotMatch = new JobResult public static readonly JobResult ConditionPrecheckDoesNotMatch = new JobResult
{ {
SkipReason = SkipReason.ConditionPrecheckDoesNotMatch SkipReason = SkipReason.ConditionPrecheckDoesNotMatch
@ -57,6 +53,10 @@ public sealed record JobResult
SkipReason = SkipReason.WrongEventForTrigger SkipReason = SkipReason.WrongEventForTrigger
}; };
public DomainId RuleId { get; set; }
public Rule Rule { get; init; }
public RuleJob? Job { get; init; } public RuleJob? Job { get; init; }
public EnrichedEvent? EnrichedEvent { get; init; } public EnrichedEvent? EnrichedEvent { get; init; }
@ -65,6 +65,19 @@ public sealed record JobResult
public SkipReason SkipReason { get; init; } 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) public static JobResult Failed(Exception exception, EnrichedEvent? enrichedEvent = null, RuleJob? job = null)
{ {
return new JobResult 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.Domain.Apps.Core.Rules;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Core.HandleRules; 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 readonly struct RuleContext
{ {
public NamedId<DomainId> AppId { get; init; } public NamedId<DomainId> AppId { get; init; }
@ -21,4 +39,18 @@ public readonly struct RuleContext
public bool IncludeSkipped { get; init; } public bool IncludeSkipped { get; init; }
public bool IncludeStale { 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 ExecutionTimeoutInSeconds { get; set; } = 3;
public int MaxEnrichedEvents { get; set; } = 500;
public TimeSpan RulesCacheDuration { get; set; } = TimeSpan.FromSeconds(10); 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.Reflection;
using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Tasks;
#pragma warning disable SA1401 // Fields should be private
namespace Squidex.Domain.Apps.Core.HandleRules; namespace Squidex.Domain.Apps.Core.HandleRules;
public sealed class RuleService : IRuleService public sealed class RuleService : IRuleService
@ -30,6 +32,17 @@ public sealed class RuleService : IRuleService
private readonly IJsonSerializer serializer; private readonly IJsonSerializer serializer;
private readonly ILogger<RuleService> log; 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 IClock Clock { get; set; } = SystemClock.Instance;
public RuleService( public RuleService(
@ -50,11 +63,9 @@ public sealed class RuleService : IRuleService
this.log = log; this.log = log;
} }
public bool CanCreateSnapshotEvents(RuleContext context) public bool CanCreateSnapshotEvents(Rule rule)
{ {
Guard.NotNull(context.Rule, nameof(context.Rule)); if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler))
if (!ruleTriggerHandlers.TryGetValue(context.Rule.Trigger.GetType(), out var triggerHandler))
{ {
return false; return false;
} }
@ -65,8 +76,6 @@ public sealed class RuleService : IRuleService
public async IAsyncEnumerable<JobResult> CreateSnapshotJobsAsync(RuleContext context, public async IAsyncEnumerable<JobResult> CreateSnapshotJobsAsync(RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default) [EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(context.Rule, nameof(context.Rule));
var rule = context.Rule; var rule = context.Rule;
if (!rule.IsEnabled && !context.IncludeSkipped) if (!rule.IsEnabled && !context.IncludeSkipped)
@ -91,6 +100,9 @@ public sealed class RuleService : IRuleService
var now = Clock.GetCurrentInstant(); 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)) await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct))
{ {
JobResult? job; JobResult? job;
@ -98,12 +110,13 @@ public sealed class RuleService : IRuleService
{ {
await eventEnricher.EnrichAsync(enrichedEvent, null); await eventEnricher.EnrichAsync(enrichedEvent, null);
if (!triggerHandler.Trigger(enrichedEvent, context)) if (!triggerHandler.Trigger(enrichedEvent, context.Rule.Trigger))
{ {
continue; continue;
} }
job = await CreateJobAsync(actionHandler, enrichedEvent, context, now); job = await CreateJobAsync(enrichedEvent, actionHandler, context.RuleId, context.Rule, now);
job.Offset = offset++;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -114,68 +127,135 @@ public sealed class RuleService : IRuleService
} }
} }
public async IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RuleContext context, public IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RulesContext context,
[EnumeratorCancellation] CancellationToken ct = default) 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, var typed = @event.To<AppEvent>();
CancellationToken ct)
{
try
{
var skipReason = SkipReason.None;
var rule = context.Rule;
if (!rule.IsEnabled) if (typed.Payload.FromRule)
{
if (context.IncludeSkipped)
{ {
// For the simulation we want to proceed as much as possible. foreach (var state in states)
if (context.IncludeSkipped)
{ {
skipReason |= SkipReason.Disabled; state.Skip |= SkipReason.FromRule;
} }
else }
else
{
foreach (var state in states)
{ {
jobs.Add(JobResult.Disabled); yield return new JobResult
return; {
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); foreach (var state in states)
return; {
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) if (context.IncludeSkipped)
{ {
skipReason |= SkipReason.FromRule; state.Skip = SkipReason.Disabled;
} }
else else
{ {
jobs.Add(JobResult.FromRule); yield return new JobResult
return; {
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)) if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler))
{ {
jobs.Add(JobResult.NoTrigger); yield return new JobResult
return; {
Rule = state.Rule,
RuleId = state.RuleId,
SkipReason = SkipReason.NoTrigger
};
continue;
} }
if (!triggerHandler.Handles(typed.Payload)) if (!triggerHandler.Handles(typed.Payload))
{ {
jobs.Add(JobResult.WrongEventForTrigger); yield return new JobResult
return; {
} Rule = state.Rule,
RuleId = state.RuleId,
SkipReason = SkipReason.WrongEventForTrigger
};
if (!ruleActionHandlers.TryGetValue(actionType, out var actionHandler)) continue;
{
jobs.Add(JobResult.NoAction);
return;
} }
var now = Clock.GetCurrentInstant(); if (!ruleActionHandlers.TryGetValue(actionType, out state.ActionHandler!))
var eventTime =
@event.Headers.ContainsKey(CommonHeaders.Timestamp) ?
@event.Headers.Timestamp() :
now;
if (!context.IncludeStale && eventTime.Plus(Constants.StaleTime) < now)
{ {
// For the simulation we want to proceed as much as possible. yield return new JobResult
if (context.IncludeSkipped)
{ {
skipReason |= SkipReason.TooOld; Rule = state.Rule,
} RuleId = state.RuleId,
else SkipReason = SkipReason.NoAction
{ };
jobs.Add(JobResult.TooOld);
return; 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) if (context.IncludeSkipped)
{ {
skipReason |= SkipReason.ConditionPrecheckDoesNotMatch; state.Skip = SkipReason.ConditionPrecheckDoesNotMatch;
} }
else else
{ {
jobs.Add(JobResult.ConditionPrecheckDoesNotMatch); yield return new JobResult
return; {
Rule = state.Rule,
RuleId = state.RuleId,
SkipReason = SkipReason.ConditionPrecheckDoesNotMatch
};
continue;
} }
} }
await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(typed, context, ct)) matchingRules ??= new ();
{ matchingRules.GetOrAddNew(triggerHandler).Add(state);
if (string.IsNullOrWhiteSpace(enrichedEvent.Name)) }
{
enrichedEvent.Name = GetName(typed.Payload);
}
try if (matchingRules == null)
{ {
await eventEnricher.EnrichAsync(enrichedEvent, typed); 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. log.LogError(ex, "Failed to create rule jobs from trigger.");
if (context.IncludeSkipped)
{ return states.Select(state =>
skipReason |= SkipReason.ConditionDoesNotMatch; new JobResult
} {
else Rule = state.Rule,
{ RuleId = state.RuleId,
jobs.Add(JobResult.ConditionDoesNotMatch); SkipReason = SkipReason.Failed,
return; });
} });
}
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 await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(@event, context, ct).Take(takeEvents).WithCancellation(ct))
if (skipReason != SkipReason.None) {
// 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,
{ Rule = state.Rule,
EnrichedEvent = enrichedEvent, RuleId = state.RuleId,
EnrichmentError = ex, SkipReason = SkipReason.ConditionDoesNotMatch
SkipReason = SkipReason.Failed };
});
}
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 actionName = typeRegistry.GetName<RuleAction>(actionType);
var expires = now.Plus(Constants.ExpirationTime); var expires = now.Plus(Constants.ExpirationTime);
@ -310,12 +448,12 @@ public sealed class RuleService : IRuleService
EventName = enrichedEvent.Name, EventName = enrichedEvent.Name,
ExecutionPartition = enrichedEvent.Partition, ExecutionPartition = enrichedEvent.Partition,
Expires = expires, Expires = expires,
RuleId = context.RuleId RuleId = ruleId
}; };
try 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); var json = serializer.Serialize(data);
@ -323,7 +461,13 @@ public sealed class RuleService : IRuleService
job.ActionName = actionName; job.ActionName = actionName;
job.Description = description; job.Description = description;
return new JobResult { Job = job, EnrichedEvent = enrichedEvent }; return new JobResult
{
EnrichedEvent = enrichedEvent,
Rule = rule,
RuleId = ruleId,
Job = job,
};
} }
catch (Exception ex) 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, internal static ScriptExecutionContext<T> Extend<T>(this ScriptExecutionContext<T> context,
ScriptVars vars, ScriptVars vars,
ScriptOptions options) ScriptOptions options)
{ {
var engine = context.Engine; 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Diagnostics;
using Jint; using Jint;
using Squidex.Infrastructure.Tasks; 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 TaskCompletionSource<T> tcs = new TaskCompletionSource<T>();
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly ReaderWriterLockSlim slimLock = new ReaderWriterLockSlim();
private int pendingTasks = 1; private int pendingTasks = 1;
public bool IsCompleted public bool IsCompleted
@ -62,10 +60,9 @@ public sealed class ScriptExecutionContext<T> : ScriptExecutionContext, ISchedul
async Task ScheduleAsync() async Task ScheduleAsync()
{ {
TryStart();
try try
{ {
TryStart();
await action(this, cancellationToken); await action(this, cancellationToken);
TryComplete(default!); TryComplete(default!);
@ -86,21 +83,20 @@ public sealed class ScriptExecutionContext<T> : ScriptExecutionContext, ISchedul
return; return;
} }
lock (Engine) TryStart();
try
{ {
try lock (Engine)
{ {
TryStart();
Engine.ResetConstraints(); Engine.ResetConstraints();
action(); 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; return;
} }
lock (Engine) TryStart();
try
{ {
try lock (Engine)
{ {
TryStart();
Engine.ResetConstraints(); Engine.ResetConstraints();
action(argument); 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() private void TryStart()
{ {
Interlocked.Increment(ref pendingTasks); Interlocked.Increment(ref pendingTasks);
Debug.WriteLine(pendingTasks);
} }
private void TryComplete(T result) private void TryComplete(T result)
@ -147,8 +140,6 @@ public sealed class ScriptExecutionContext<T> : ScriptExecutionContext, ISchedul
{ {
tcs.TrySetResult(result); 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); 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, public IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
CancellationToken ct) 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) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoContentCollection/HasReferrersAsync")) 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); 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, public IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
CancellationToken ct = default) 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) CancellationToken ct = default)
{ {
if (scope == SearchScope.All) if (scope == SearchScope.All)
{ {
return collectionComplete.HasReferrersAsync(appId, contentId, ct); return collectionComplete.HasReferrersAsync(appId, reference, ct);
} }
else 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 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, public async IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
[EnumeratorCancellation] CancellationToken ct) [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, 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>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {
@ -176,9 +176,9 @@ internal sealed class QueryByQuery : OperationBase
isDefault = false; 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; 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, private static FilterDefinition<MongoContentEntity> CreateFilter(ClrQuery? query,
DomainId referenced, RefToken? createdBy) DomainId reference, RefToken? createdBy)
{ {
var filters = new List<FilterDefinition<MongoContentEntity>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {
@ -183,9 +183,9 @@ internal sealed class QueryInDedicatedCollection : MongoBase<MongoContentEntity>
filters.Add(query.Filter.BuildFilter<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) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Runtime.CompilerServices;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
@ -21,15 +23,10 @@ internal sealed class QueryReferrers : OperationBase
.Ascending(x => x.IsDeleted)); .Ascending(x => x.IsDeleted));
} }
public async Task<bool> CheckExistsAsync(DomainId appId, DomainId contentId, public async Task<bool> CheckExistsAsync(DomainId appId, DomainId reference,
CancellationToken ct) CancellationToken ct)
{ {
var filter = var filter = BuildFilter(appId, reference);
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 hasReferrerAsync = var hasReferrerAsync =
await Collection.Find(filter).Only(x => x.Id) await Collection.Find(filter).Only(x => x.Id)
@ -37,4 +34,30 @@ internal sealed class QueryReferrers : OperationBase
return hasReferrerAsync; 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.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
@ -80,8 +81,10 @@ public sealed class MongoRuleEventEntity : IRuleEventEntity
get => JobId; 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 var entity = new MongoRuleEventEntity
{ {
Job = job, Job = job,
@ -91,6 +94,14 @@ public sealed class MongoRuleEventEntity : IRuleEventEntity
SimpleMapper.Map(job, entity); 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; return entity;
} }
} }

42
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs

@ -7,7 +7,6 @@
using MongoDB.Driver; using MongoDB.Driver;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Rules; 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 public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEventEntity>, IRuleEventRepository, IDeleter
{ {
private readonly MongoRuleStatisticsCollection statisticsCollection;
public MongoRuleEventRepository(IMongoDatabase database) public MongoRuleEventRepository(IMongoDatabase database)
: base(database) : base(database)
{ {
statisticsCollection = new MongoRuleStatisticsCollection(database);
} }
protected override string CollectionName() protected override string CollectionName()
@ -35,8 +31,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
protected override async Task SetupCollectionAsync(IMongoCollection<MongoRuleEventEntity> collection, protected override async Task SetupCollectionAsync(IMongoCollection<MongoRuleEventEntity> collection,
CancellationToken ct) CancellationToken ct)
{ {
await statisticsCollection.InitializeAsync(ct);
await collection.Indexes.CreateManyAsync(new[] await collection.Indexes.CreateManyAsync(new[]
{ {
new CreateIndexModel<MongoRuleEventEntity>( new CreateIndexModel<MongoRuleEventEntity>(
@ -58,8 +52,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
async Task IDeleter.DeleteAppAsync(IAppEntity app, async Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) CancellationToken ct)
{ {
await statisticsCollection.DeleteAppAsync(app.Id, ct);
await Collection.DeleteManyAsync(Filter.Eq(x => x.AppId, 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); 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, public Task CancelByEventAsync(DomainId id,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@ -150,14 +134,6 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
Guard.NotNull(job); Guard.NotNull(job);
Guard.NotNull(update); 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, return Collection.UpdateOneAsync(x => x.JobId == job.Id,
Update Update
.Set(x => x.Result, update.ExecutionResult) .Set(x => x.Result, update.ExecutionResult)
@ -168,22 +144,14 @@ public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEven
cancellationToken: ct); cancellationToken: ct);
} }
private async Task UpdateStatisticsAsync(RuleJob job, RuleJobUpdate update, public async Task EnqueueAsync(List<RuleEventWrite> jobs,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (update.ExecutionResult == RuleResult.Success) var entities = jobs.Select(MongoRuleEventEntity.FromJob).ToList();
{
await statisticsCollection.IncrementSuccessAsync(job.AppId, job.RuleId, update.Finished, ct); if (entities.Count > 0)
}
else
{ {
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 System.Runtime.CompilerServices;
using Squidex.Domain.Apps.Core.HandleRules; 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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -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) [EnumeratorCancellation] CancellationToken ct)
{ {
yield return await CreateEnrichedEventsCoreAsync(@event, ct); yield return await CreateEnrichedEventsCoreAsync(@event, ct);
@ -121,11 +122,11 @@ public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscript
return result; 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; return true;
} }
@ -136,6 +137,6 @@ public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscript
Event = @event 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.Core.Tags;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
#pragma warning disable CS0649 #pragma warning disable CS0649
@ -17,9 +16,9 @@ namespace Squidex.Domain.Apps.Entities.Assets;
public partial class AssetUsageTracker : IDeleter public partial class AssetUsageTracker : IDeleter
{ {
private readonly IAssetLoader assetLoader; private readonly IAssetLoader assetLoader;
private readonly IAssetUsageTracker assetUsageTracker;
private readonly ISnapshotStore<State> store; private readonly ISnapshotStore<State> store;
private readonly ITagService tagService; private readonly ITagService tagService;
private readonly IUsageGate usageGate;
[CollectionName("Index_TagHistory")] [CollectionName("Index_TagHistory")]
public sealed class State public sealed class State
@ -27,11 +26,14 @@ public partial class AssetUsageTracker : IDeleter
public HashSet<string>? Tags { get; set; } 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) ISnapshotStore<State> store)
{ {
this.usageGate = usageGate;
this.assetLoader = assetLoader; this.assetLoader = assetLoader;
this.assetUsageTracker = assetUsageTracker;
this.tagService = tagService; this.tagService = tagService;
this.store = store; this.store = store;
@ -41,6 +43,6 @@ public partial class AssetUsageTracker : IDeleter
Task IDeleter.DeleteAppAsync(IAppEntity app, Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) 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. // Will not remove data, but reset alls counts to zero.
await tagService.ClearAsync(); 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. // Also clear the store and cache, because otherwise we would use data from the future when querying old tags.
ClearCache(); ClearCache();
await store.ClearAsync(); await store.ClearAsync();
await usageGate.DeleteAssetsUsageAsync();
} }
public async Task On(IEnumerable<Envelope<IEvent>> events) public async Task On(IEnumerable<Envelope<IEvent>> events)
@ -185,20 +186,20 @@ public partial class AssetUsageTracker : IEventConsumer
switch (@event.Payload) switch (@event.Payload)
{ {
case AssetCreated assetCreated: 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: 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: 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; 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; 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; namespace Squidex.Domain.Apps.Entities.Assets;
public interface IAssetUsageTracker 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); CancellationToken ct = default);
Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate, Task<AssetCounters> GetTotalByTeamAsync(DomainId teamId,
CancellationToken ct = default); CancellationToken ct = default);
Task<long> GetTotalSizeByAppAsync(DomainId appId, Task TrackAsync(DomainId appId, DateOnly date, long fileSize, long count,
CancellationToken ct = default); CancellationToken ct = default);
Task<long> GetTotalSizeByTeamAsync(DomainId teamId, Task DeleteUsageAsync(DomainId appId,
CancellationToken ct = default);
Task DeleteUsageAsync(
CancellationToken ct = default); 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 public interface IUsageGate
{ {
Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateOnly date,
CancellationToken ct = default); CancellationToken ct = default);
Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes, Task TrackRequestAsync(IAppEntity app, string? clientId, DateOnly 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(
CancellationToken ct = default); CancellationToken ct = default);
Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app, bool canCache, 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 Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.UsageTracking; using Squidex.Infrastructure.UsageTracking;
@ -17,11 +16,9 @@ using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Billing; namespace Squidex.Domain.Apps.Entities.Billing;
public sealed class UsageGate : IUsageGate, IAssetUsageTracker public sealed partial class UsageGate : IUsageGate
{ {
private const string CounterTotalCount = "TotalAssets"; private static readonly DateOnly SummaryDate = default;
private const string CounterTotalSize = "TotalSize";
private static readonly DateTime SummaryDate = default;
private readonly IApiUsageTracker apiUsageTracker; private readonly IApiUsageTracker apiUsageTracker;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IBillingPlans billingPlans; private readonly IBillingPlans billingPlans;
@ -43,79 +40,7 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker
this.usageTracker = usageTracker; this.usageTracker = usageTracker;
} }
public Task DeleteAssetUsageAsync(DomainId appId, public async Task TrackRequestAsync(IAppEntity app, string? clientId, DateOnly date, double costs, long elapsedMs, long bytes,
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,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var appId = app.Id.ToString(); 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); 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) CancellationToken ct = default)
{ {
Guard.NotNull(app); Guard.NotNull(app);
@ -196,7 +121,7 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker
return usage > limit * 0.1; 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); var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
@ -205,36 +130,6 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker
return forecasted > limit; 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, public Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app, bool canCache,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@ -311,16 +206,6 @@ public sealed class UsageGate : IUsageGate, IAssetUsageTracker
return Task.FromResult((plan, planId)); 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) private static string CacheKey(DomainId appId)
{ {
return $"{appId}_Plan"; return $"{appId}_Plan";

11
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs

@ -7,6 +7,7 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Squidex.Domain.Apps.Core.HandleRules; 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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -37,7 +38,7 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler
return @event is CommentCreated; 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) [EnumeratorCancellation] CancellationToken ct)
{ {
var commentCreated = (CommentCreated)@event.Payload; 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; return true;
} }
@ -85,6 +86,6 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler
Event = @event 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 System.Runtime.CompilerServices;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -16,6 +17,7 @@ using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Text; 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) [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, public async ValueTask<EnrichedEvent?> CreateEnrichedEventsAsync(Envelope<AppEvent> @event,
@ -87,7 +127,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri
return await CreateEnrichedEventsCoreAsync(@event, ct); return await CreateEnrichedEventsCoreAsync(@event, ct);
} }
private async ValueTask<EnrichedEvent> CreateEnrichedEventsCoreAsync(Envelope<AppEvent> @event, private async ValueTask<EnrichedContentEvent> CreateEnrichedEventsCoreAsync(Envelope<AppEvent> @event,
CancellationToken ct) CancellationToken ct)
{ {
var contentEvent = (ContentEvent)@event.Payload; 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. // Use the concrete event to map properties that are not part of app event.
SimpleMapper.Map(contentEvent, result); SimpleMapper.Map(contentEvent, result);
// This property has another name, so we cannot use the simple mapper.
result.Id = contentEvent.ContentId;
switch (@event.Payload) switch (@event.Payload)
{ {
case ContentCreated: case ContentCreated:
@ -173,62 +216,87 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri
return null; 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; 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; return false;
} }
public bool Trigger(EnrichedEvent @event, RuleContext context) private bool MatchesAnySchema(ReadonlyList<SchemaCondition>? schemas, AppEvent @event)
{ {
var trigger = (ContentChangedTriggerV2)context.Rule.Trigger; if (schemas == null)
if (trigger.HandleAll)
{ {
return true; return false;
} }
if (trigger.Schemas != null) var contentEvent = (ContentEvent)@event;
{
var contentEvent = (EnrichedContentEvent)@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; 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; 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)) 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, IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
CancellationToken ct = default); 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, Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope,
CancellationToken ct = default); CancellationToken ct = default);
@ -34,7 +37,7 @@ public interface IContentRepository
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope, Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope,
CancellationToken ct = default); CancellationToken ct = default);
Task<bool> HasReferrersAsync(DomainId appId, DomainId contentId, SearchScope scope, Task<bool> HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope,
CancellationToken ct = default); CancellationToken ct = default);
Task ResetScheduledAsync(DomainId documentId, 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; return errors;
} }
private async Task<ValidationError?> CheckSchemaAsync(ContentChangedTriggerSchemaV2 schema) private async Task<ValidationError?> CheckSchemaAsync(SchemaCondition schema)
{ {
if (await SchemaProvider(schema.SchemaId) == null) 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(command, @event);
SimpleMapper.Map(Snapshot, @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() 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using NodaTime;
namespace Squidex.Domain.Apps.Entities.Rules; namespace Squidex.Domain.Apps.Entities.Rules;
public interface IEnrichedRuleEntity : IRuleEntity public interface IEnrichedRuleEntity : IRuleEntity
{ {
int NumSucceeded { get; } long NumSucceeded { get; }
int NumFailed { 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 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; 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) [EnumeratorCancellation] CancellationToken ct)
{ {
var result = new EnrichedManualEvent(); 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
@ -14,13 +13,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries;
public sealed class RuleEnricher : IRuleEnricher public sealed class RuleEnricher : IRuleEnricher
{ {
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleUsageTracker ruleUsageTracker;
private readonly IRequestCache requestCache; 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; this.requestCache = requestCache;
} }
@ -53,22 +51,20 @@ public sealed class RuleEnricher : IRuleEnricher
foreach (var group in results.GroupBy(x => x.AppId.Id)) 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) foreach (var rule in group)
{ {
requestCache.AddDependency(rule.UniqueId, rule.Version); requestCache.AddDependency(rule.UniqueId, rule.Version);
var statistic = statistics.FirstOrDefault(x => x.RuleId == rule.Id); if (statistics.TryGetValue(rule.Id, out var statistic))
if (statistic != null)
{ {
rule.LastExecuted = statistic.LastExecuted; rule.NumFailed = statistic.TotalFailed;
rule.NumFailed = statistic.NumFailed; rule.NumSucceeded = statistic.TotalSucceeded;
rule.NumSucceeded = statistic.NumSucceeded;
requestCache.AddDependency(rule.LastExecuted);
} }
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 NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure; using Squidex.Infrastructure;
#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; namespace Squidex.Domain.Apps.Entities.Rules.Repositories;
public record struct RuleEventWrite(RuleJob Job, Instant? NextAttempt = null, Exception? Error = null);
public interface IRuleEventRepository 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, Task UpdateAsync(RuleJob job, RuleJobUpdate update,
CancellationToken ct = default); CancellationToken ct = default);
Task EnqueueAsync(RuleJob job, Instant? nextAttempt, Task EnqueueAsync(List<RuleEventWrite> jobs,
CancellationToken ct = default); CancellationToken ct = default);
Task EnqueueAsync(DomainId id, Instant nextAttempt, Task EnqueueAsync(DomainId id, Instant nextAttempt,
@ -56,9 +39,6 @@ public interface IRuleEventRepository
Task QueryPendingAsync(Instant now, Func<IRuleEventEntity, Task> callback, Task QueryPendingAsync(Instant now, Func<IRuleEventEntity, Task> callback,
CancellationToken ct = default); 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, Task<IResultList<IRuleEventEntity>> QueryByAppAsync(DomainId appId, DomainId? ruleId = null, int skip = 0, int take = 20,
CancellationToken ct = default); 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 ITargetBlock<IRuleEventEntity> requestBlock;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleService ruleService; private readonly IRuleService ruleService;
private readonly IRuleUsageTracker ruleUsageTracker;
private readonly ILogger<RuleDequeuerWorker> log; private readonly ILogger<RuleDequeuerWorker> log;
private CompletionTimer timer; private CompletionTimer timer;
@ -32,11 +33,13 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
public RuleDequeuerWorker( public RuleDequeuerWorker(
IRuleService ruleService, IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
IRuleEventRepository ruleEventRepository, IRuleEventRepository ruleEventRepository,
ILogger<RuleDequeuerWorker> log) ILogger<RuleDequeuerWorker> log)
{ {
this.ruleEventRepository = ruleEventRepository; this.ruleEventRepository = ruleEventRepository;
this.ruleService = ruleService; this.ruleService = ruleService;
this.ruleUsageTracker = ruleUsageTracker;
this.log = log; this.log = log;
requestBlock = requestBlock =
@ -109,10 +112,16 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
if (response.Status == RuleResult.Failed) 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}.", log.LogWarning(response.Exception, "Failed to execute rule event with rule id {ruleId}/{description}.",
@event.Job.RuleId, @event.Job.RuleId,
@event.Job.Description); @event.Job.Description);
} }
else
{
await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 1, 0);
}
} }
catch (Exception ex) 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.Entities.Rules.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules; namespace Squidex.Domain.Apps.Entities.Rules;
@ -22,11 +23,18 @@ public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer
{ {
private readonly IMemoryCache cache; private readonly IMemoryCache cache;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleUsageTracker ruleUsageTracker;
private readonly IRuleService ruleService; private readonly IRuleService ruleService;
private readonly ILogger<RuleEnqueuer> log; private readonly ILogger<RuleEnqueuer> log;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly ILocalCache localCache; private readonly ILocalCache localCache;
private readonly TimeSpan cacheDuration; private readonly TimeSpan cacheDuration;
private readonly int maxExtraEvents;
public int BatchSize
{
get => 200;
}
public string Name public string Name
{ {
@ -37,6 +45,7 @@ public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer
IAppProvider appProvider, IAppProvider appProvider,
IRuleEventRepository ruleEventRepository, IRuleEventRepository ruleEventRepository,
IRuleService ruleService, IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
IOptions<RuleOptions> options, IOptions<RuleOptions> options,
ILogger<RuleEnqueuer> log) ILogger<RuleEnqueuer> log)
{ {
@ -44,53 +53,77 @@ public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer
this.cache = cache; this.cache = cache;
this.cacheDuration = options.Value.RulesCacheDuration; this.cacheDuration = options.Value.RulesCacheDuration;
this.ruleEventRepository = ruleEventRepository; this.ruleEventRepository = ruleEventRepository;
this.ruleUsageTracker = ruleUsageTracker;
this.ruleService = ruleService; this.ruleService = ruleService;
this.log = log; this.log = log;
this.localCache = localCache; 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(rule);
Guard.NotNull(@event, nameof(@event)); Guard.NotNull(@event, nameof(@event));
var ruleContext = new RuleContext if (@event.Payload is not AppEvent appEvent)
{ {
Rule = rule, return;
RuleId = ruleId }
};
var jobs = ruleService.CreateJobsAsync(@event, ruleContext);
await foreach (var job in jobs) var context = new RulesContext
{ {
// We do not want to handle disabled rules in the normal flow. AppId = appEvent.AppId,
if (job.Job != null && job.SkipReason is SkipReason.None or SkipReason.Failed) IncludeSkipped = false,
IncludeStale = false,
Rules = new Dictionary<DomainId, Rule>
{ {
log.LogInformation("Adding rule job {jobId} for Rule(action={ruleAction}, trigger={ruleTrigger})", job.Job.Id, [ruleId] = rule
rule.Action.GetType().Name, rule.Trigger.GetType().Name); }.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) foreach (var @event in events)
{
using (localCache.StartContext())
{ {
if (@event.Headers.Restored())
{
continue;
}
if (@event.Payload is not AppEvent appEvent)
{
continue;
}
var rules = await GetRulesAsync(appEvent.AppId.Id); 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 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; } 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.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Messaging; using Squidex.Messaging;
@ -51,13 +52,15 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService
{ {
Guard.NotNull(rule); Guard.NotNull(rule);
var context = new RuleContext var context = new RulesContext
{ {
AppId = appId, AppId = appId,
Rule = rule,
RuleId = ruleId,
IncludeSkipped = true, IncludeSkipped = true,
IncludeStale = true IncludeStale = true,
Rules = new Dictionary<DomainId, Rule>
{
[ruleId] = rule
}.ToReadonlyDictionary()
}; };
var simulatedEvents = new List<SimulatedRuleEvent>(MaxSimulatedEvents); var simulatedEvents = new List<SimulatedRuleEvent>(MaxSimulatedEvents);
@ -68,30 +71,35 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService
{ {
var @event = eventFormatter.ParseIfKnown(storedEvent); 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. var eventName = job.Job?.EventName;
await foreach (var result in ruleService.CreateJobsAsync(@event, context, ct).WithCancellation(ct))
if (string.IsNullOrWhiteSpace(eventName))
{ {
var eventName = result.Job?.EventName; eventName = ruleService.GetName(appEvent);
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
});
} }
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) public bool CanRunRule(IRuleEntity rule)
{ {
var context = GetContext(rule); return rule.RuleDef.Trigger is not ManualTrigger;
return context.Rule.Trigger is not ManualTrigger;
} }
public bool CanRunFromSnapshots(IRuleEntity rule) public bool CanRunFromSnapshots(IRuleEntity rule)
{ {
var context = GetContext(rule); return rule.RuleDef.Trigger is not ManualTrigger && ruleService.CanCreateSnapshotEvents(rule.RuleDef);
return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(context);
} }
public Task CancelAsync(DomainId appId, public Task CancelAsync(DomainId appId,
@ -141,14 +145,4 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService
return state; 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 ILocalCache localCache;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleService ruleService; private readonly IRuleService ruleService;
private readonly IRuleUsageTracker ruleUsageTracker;
private readonly ILogger<RuleRunnerProcessor> log; private readonly ILogger<RuleRunnerProcessor> log;
private readonly SimpleState<RuleRunnerState> state; private readonly SimpleState<RuleRunnerState> state;
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1);
@ -78,6 +79,7 @@ public sealed class RuleRunnerProcessor
IPersistenceFactory<RuleRunnerState> persistenceFactory, IPersistenceFactory<RuleRunnerState> persistenceFactory,
IRuleEventRepository ruleEventRepository, IRuleEventRepository ruleEventRepository,
IRuleService ruleService, IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
ILogger<RuleRunnerProcessor> log) ILogger<RuleRunnerProcessor> log)
{ {
this.appId = appId; this.appId = appId;
@ -87,6 +89,7 @@ public sealed class RuleRunnerProcessor
this.eventFormatter = eventFormatter; this.eventFormatter = eventFormatter;
this.ruleEventRepository = ruleEventRepository; this.ruleEventRepository = ruleEventRepository;
this.ruleService = ruleService; this.ruleService = ruleService;
this.ruleUsageTracker = ruleUsageTracker;
this.log = log; this.log = log;
state = new SimpleState<RuleRunnerState>(persistenceFactory, GetType(), appId); state = new SimpleState<RuleRunnerState>(persistenceFactory, GetType(), appId);
@ -188,7 +191,7 @@ public sealed class RuleRunnerProcessor
IncludeSkipped = true IncludeSkipped = true
}; };
if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(run.Context)) if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(rule.RuleDef))
{ {
await EnqueueFromSnapshotsAsync(run, ct); 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. // We collect errors and allow a few erors before we throw an exception.
var errors = 0; 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 batch.WriteAsync(result);
{
await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError, ct); if (result.EnrichmentError != null)
}
else if (job.EnrichmentError != null)
{ {
errors++; errors++;
// We accept a few errors and stop the process if there are too many errors.
if (errors >= MaxErrors) 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. // We collect errors and allow a few erors before we throw an exception.
var errors = 0; 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. // Use a prefix query so that the storage can use an index for the query.
var filter = $"^([a-z]+)\\-{appId}"; var filter = $"^([a-z]+)\\-{appId}";
await foreach (var storedEvent in eventStore.QueryAllAsync(filter, run.Job.Position, ct: ct)) 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) throw result.EnrichmentError;
{
await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError, ct);
}
} }
}
}
catch (Exception ex)
{
errors++;
if (errors >= MaxErrors) log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.RuleId);
{
throw;
} }
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); 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 Guid EventId { get; init; }
public string UniqueId { get; init; }
public string EventName { get; init; } public string EventName { get; init; }
public object Event { 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); 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); continue;
}
var limit = target.Limits;
if (costs > limit) var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today.ToDateOnly(), null);
{
target.Triggered = today;
var @event = new AppUsageExceeded var limit = target.Limits;
{
AppId = target.AppId,
CallsCurrent = costs,
CallsLimit = limit,
RuleId = key
};
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(); await state.WriteAsync();

9
backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs

@ -7,6 +7,7 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Squidex.Domain.Apps.Core.HandleRules; 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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
@ -25,7 +26,7 @@ public sealed class UsageTriggerHandler : IRuleTriggerHandler
return appEvent is AppUsageExceeded; 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) [EnumeratorCancellation] CancellationToken ct)
{ {
var usageEvent = (AppUsageExceeded)@event.Payload; var usageEvent = (AppUsageExceeded)@event.Payload;
@ -42,12 +43,10 @@ public sealed class UsageTriggerHandler : IRuleTriggerHandler
yield return result; 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; 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 System.Runtime.CompilerServices;
using Squidex.Domain.Apps.Core.HandleRules; 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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -33,7 +34,7 @@ public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler
return appEvent is SchemaEvent; 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) [EnumeratorCancellation] CancellationToken ct)
{ {
var result = new EnrichedSchemaEvent(); var result = new EnrichedSchemaEvent();
@ -71,11 +72,11 @@ public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler
yield return result; 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; return true;
} }
@ -86,6 +87,6 @@ public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler
["event"] = @event ["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); 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) 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.NotNullOrEmpty(streamName);
Guard.NotNull(events); Guard.NotNull(events);
Guard.GreaterEquals(expectedVersion, EtagVersion.Any);
using (Telemetry.Activities.StartActivity("GetEventStore/AppendEventsInternalAsync")) 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); 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, public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct = default) 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) else if (updates.Length > 0)
{ {
var writes = new List<WriteModel<MongoUsage>>(); var writes = new List<WriteModel<MongoUsage>>(updates.Length);
foreach (var update in updates) 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 var update = Update
.SetOnInsert(x => x.Key, usageUpdate.Key) .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); .SetOnInsert(x => x.Category, usageUpdate.Category);
foreach (var (key, value) in usageUpdate.Counters) foreach (var (key, value) in usageUpdate.Counters)
@ -110,11 +113,14 @@ public sealed class MongoUsageRepository : MongoRepositoryBase<MongoUsage>, IUsa
return (filter, update); 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) 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.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
namespace Squidex.Infrastructure; namespace Squidex.Infrastructure;
@ -422,4 +423,52 @@ public static class CollectionExtensions
yield return takenElement.Value; 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, IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, int take = int.MaxValue,
CancellationToken ct = default); 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, Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,
CancellationToken ct = default); CancellationToken ct = default);

10
backend/src/Squidex.Infrastructure/InstantExtensions.cs

@ -20,4 +20,14 @@ public static class InstantExtensions
{ {
return Instant.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds()); 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)); 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); 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)) await foreach (var item in source.Reader.ReadAllAsync(ct))
{ {
if (ReferenceEquals(item, force)) if (ReferenceEquals(item, force))
@ -84,15 +84,11 @@ public static class AsyncHelper
} }
else if (item is TIn typed) 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); timer.Change(timeout, Timeout.Infinite);
batch.Add(typed); batch.Add(typed);
await TrySendAsync(batchSize - 1);
if (batch.Count >= batchSize)
{
await TrySendAsync();
}
} }
} }

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

@ -9,4 +9,4 @@
namespace Squidex.Infrastructure.UsageTracking; 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); 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) CancellationToken ct = default)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
@ -37,7 +37,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker
return counters.GetInt64(CounterTotalCalls); 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) CancellationToken ct = default)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
@ -47,7 +47,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker
return counters.GetInt64(CounterTotalBytes); 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) CancellationToken ct = default)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
@ -62,7 +62,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker
return usageTracker.TrackAsync(date, apiKey, category, counters, ct); 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) CancellationToken ct = default)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
@ -98,7 +98,7 @@ public sealed class ApiUsageTracker : IApiUsageTracker
var summaryElapsedAvg = CalculateAverage(summaryCalls, summaryElapsed); 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( var summary = new ApiStatsSummary(
summaryElapsedAvg, 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 IUsageRepository usageRepository;
private readonly ILogger<BackgroundUsageTracker> log; private readonly ILogger<BackgroundUsageTracker> log;
private readonly CompletionTimer usageTimer; 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; private bool isUpdating;
public bool HasPendingJobs => !jobs.IsEmpty || isUpdating; public bool HasPendingJobs => !jobs.IsEmpty || isUpdating;
@ -55,7 +55,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
{ {
isUpdating = true; 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) if (!localUsages.IsEmpty)
{ {
@ -106,7 +106,7 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
return usageRepository.DeleteByKeyPatternAsync(pattern, ct); 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) CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(key); Guard.NotNullOrEmpty(key);
@ -123,21 +123,21 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
return Task.CompletedTask; 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) CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(key); Guard.NotNullOrEmpty(key);
ThrowIfDisposed(); 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 usageData = await usageRepository.QueryAsync(key, fromDate, toDate, ct);
var usageGroups = usageData.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList()); var usageGroups = usageData.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList());
if (usageGroups.Keys.Count == 0) 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)) 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) 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)) for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{ {
@ -164,16 +164,16 @@ public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
return result; 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) 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); var dateTo = dateFrom.AddMonths(1).AddDays(-1);
return GetAsync(key, dateFrom, dateTo, category, ct); 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) CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(key); 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); 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) CancellationToken ct = default)
{ {
Guard.NotNull(key); Guard.NotNull(key);
@ -47,7 +47,7 @@ public sealed class CachingUsageTracker : IUsageTracker
return inner.QueryAsync(key, fromDate, toDate, ct); 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) CancellationToken ct = default)
{ {
Guard.NotNull(key); Guard.NotNull(key);
@ -55,7 +55,7 @@ public sealed class CachingUsageTracker : IUsageTracker
return inner.TrackAsync(date, key, category, counters, ct); 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) CancellationToken ct = default)
{ {
Guard.NotNull(key); 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) CancellationToken ct = default)
{ {
Guard.NotNull(key); Guard.NotNull(key);

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

@ -12,15 +12,15 @@ public interface IApiUsageTracker
Task DeleteAsync(string key, Task DeleteAsync(string key,
CancellationToken ct = default); 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); CancellationToken ct = default);
Task<long> GetMonthCallsAsync(string key, DateTime date, string? category, Task<long> GetMonthCallsAsync(string key, DateOnly date, string? category,
CancellationToken ct = default); CancellationToken ct = default);
Task<long> GetMonthBytesAsync(string key, DateTime date, string? category, Task<long> GetMonthBytesAsync(string key, DateOnly date, string? category,
CancellationToken ct = default); 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); CancellationToken ct = default);
} }

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

@ -15,7 +15,7 @@ public interface IUsageRepository
Task TrackUsagesAsync(UsageUpdate[] updates, Task TrackUsagesAsync(UsageUpdate[] updates,
CancellationToken ct = default); 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); CancellationToken ct = default);
Task DeleteAsync(string key, Task DeleteAsync(string key,

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

@ -11,16 +11,16 @@ public interface IUsageTracker
{ {
string FallbackCategory { get; } 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); CancellationToken ct = default);
Task<Counters> GetForMonthAsync(string key, DateTime date, string? category, Task<Counters> GetForMonthAsync(string key, DateOnly date, string? category,
CancellationToken ct = default); 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); 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); CancellationToken ct = default);
Task DeleteAsync(string key, Task DeleteAsync(string key,

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

@ -9,4 +9,4 @@
namespace Squidex.Infrastructure.UsageTracking; 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 struct UsageUpdate
{ {
public DateTime Date; public DateOnly Date;
public string Key; public string Key;
@ -17,7 +17,7 @@ public struct UsageUpdate
public Counters Counters; 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; Key = key;
Category = category; 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; var app = context.HttpContext.Context().App;
if (app != null) if (app == null || FilterDefinition.Costs <= 0)
{ {
if (FilterDefinition.Costs > 0) await next();
{ return;
using (Telemetry.Activities.StartActivity("CheckUsage")) }
{
var (_, clientId) = context.HttpContext.User.GetClient();
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) var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today.ToDateOnly(), context.HttpContext.RequestAborted);
{
context.Result = new StatusCodeResult(429);
return;
}
}
}
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(); await next();
} }
} }

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

@ -7,6 +7,7 @@
namespace Squidex.Web.Pipeline; namespace Squidex.Web.Pipeline;
[AttributeUsage(AttributeTargets.All)]
public sealed class IgnoreCacheFilterAttribute : Attribute 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) if (request.Costs > 0)
{ {
var date = request.Timestamp.ToDateTimeUtc().Date; await usageGate.TrackRequestAsync(
app,
await usageGate.TrackRequestAsync(app, request.UserClientId, date, request.UserClientId,
request.Timestamp.ToDateOnly(),
request.Costs, request.Costs,
request.ElapsedMs, request.ElapsedMs,
request.Bytes, request.Bytes,

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

@ -94,6 +94,7 @@ public static class OpenApiServices
CreateArrayMap<FieldNames>(JsonObjectType.String), CreateArrayMap<FieldNames>(JsonObjectType.String),
CreateObjectMap<AssetMetadata>(), CreateObjectMap<AssetMetadata>(),
CreateObjectMap<JsonObject>(), CreateObjectMap<JsonObject>(),
CreateStringMap<DateOnly>(JsonFormatStrings.Date),
CreateStringMap<DomainId>(), CreateStringMap<DomainId>(),
CreateStringMap<Instant>(JsonFormatStrings.DateTime), CreateStringMap<Instant>(JsonFormatStrings.DateTime),
CreateStringMap<Language>(), 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 (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) 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.Areas.Api.Controllers.Rules.Models.Triggers;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters; namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters;
@ -27,33 +26,31 @@ public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor<RuleTriggerDto>
public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger) public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger)
{ {
return SimpleMapper.Map(trigger, new AssetChangedRuleTriggerDto()); return AssetChangedRuleTriggerDto.FromDomain(trigger);
} }
public RuleTriggerDto Visit(CommentTrigger trigger) public RuleTriggerDto Visit(CommentTrigger trigger)
{ {
return SimpleMapper.Map(trigger, new CommentRuleTriggerDto()); return CommentRuleTriggerDto.FromDomain(trigger);
} }
public RuleTriggerDto Visit(ManualTrigger trigger) public RuleTriggerDto Visit(ManualTrigger trigger)
{ {
return SimpleMapper.Map(trigger, new ManualRuleTriggerDto()); return ManualRuleTriggerDto.FromDomain(trigger);
} }
public RuleTriggerDto Visit(SchemaChangedTrigger trigger) public RuleTriggerDto Visit(SchemaChangedTrigger trigger)
{ {
return SimpleMapper.Map(trigger, new SchemaChangedRuleTriggerDto()); return SchemaChangedRuleTriggerDto.FromDomain(trigger);
} }
public RuleTriggerDto Visit(UsageTrigger trigger) public RuleTriggerDto Visit(UsageTrigger trigger)
{ {
return SimpleMapper.Map(trigger, new UsageRuleTriggerDto()); return UsageRuleTriggerDto.FromDomain(trigger);
} }
public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger) public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger)
{ {
var schemas = trigger.Schemas?.Select(ContentChangedRuleTriggerSchemaDto.FromDomain).ToArray(); return ContentChangedRuleTriggerDto.FromDomain(trigger);
return new ContentChangedRuleTriggerDto { Schemas = schemas, HandleAll = trigger.HandleAll };
} }
} }

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

@ -76,16 +76,17 @@ public sealed class RuleDto : Resource
/// <summary> /// <summary>
/// The number of completed executions. /// The number of completed executions.
/// </summary> /// </summary>
public int NumSucceeded { get; set; } public long NumSucceeded { get; set; }
/// <summary> /// <summary>
/// The number of failed executions. /// The number of failed executions.
/// </summary> /// </summary>
public int NumFailed { get; set; } public long NumFailed { get; set; }
/// <summary> /// <summary>
/// The date and time when the rule was executed the last time. /// The date and time when the rule was executed the last time.
/// </summary> /// </summary>
[Obsolete("Removed when migrated to new rule statistics.")]
public Instant? LastExecuted { get; set; } public Instant? LastExecuted { get; set; }
public static RuleDto FromDomain(IEnrichedRuleEntity rule, bool canRun, IRuleRunnerService ruleRunnerService, Resources resources) 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] [Required]
public Guid EventId { get; init; } public Guid EventId { get; init; }
/// <summary>
/// The the unique id of the simulated event.
/// </summary>
[Required]
public string UniqueId { get; set; }
/// <summary> /// <summary>
/// The name of the event. /// The name of the event.
/// </summary> /// </summary>

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

@ -18,6 +18,11 @@ public sealed class AssetChangedRuleTriggerDto : RuleTriggerDto
/// </summary> /// </summary>
public string? Condition { get; set; } public string? Condition { get; set; }
public static AssetChangedRuleTriggerDto FromDomain(AssetChangedTriggerV2 trigger)
{
return SimpleMapper.Map(trigger, new AssetChangedRuleTriggerDto());
}
public override RuleTrigger ToTrigger() public override RuleTrigger ToTrigger()
{ {
return SimpleMapper.Map(this, new AssetChangedTriggerV2()); 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> /// </summary>
public string? Condition { get; set; } public string? Condition { get; set; }
public static CommentRuleTriggerDto FromDomain(CommentTrigger trigger)
{
return SimpleMapper.Map(trigger, new CommentRuleTriggerDto());
}
public override RuleTrigger ToTrigger() public override RuleTrigger ToTrigger()
{ {
return SimpleMapper.Map(this, new CommentTrigger()); 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;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers; namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers;
@ -16,17 +17,25 @@ public sealed class ContentChangedRuleTriggerDto : RuleTriggerDto
/// <summary> /// <summary>
/// The schema settings. /// The schema settings.
/// </summary> /// </summary>
public ContentChangedRuleTriggerSchemaDto[]? Schemas { get; set; } public ReadonlyList<SchemaCondition>? Schemas { get; set; }
/// <summary>
/// The schema references.
/// </summary>
public ReadonlyList<SchemaCondition>? ReferencedSchemas { get; set; }
/// <summary> /// <summary>
/// Determines whether the trigger should handle all content changes events. /// Determines whether the trigger should handle all content changes events.
/// </summary> /// </summary>
public bool HandleAll { get; set; } 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 sealed class ManualRuleTriggerDto : RuleTriggerDto
{ {
public static ManualRuleTriggerDto FromDomain(ManualTrigger trigger)
{
return SimpleMapper.Map(trigger, new ManualRuleTriggerDto());
}
public override RuleTrigger ToTrigger() public override RuleTrigger ToTrigger()
{ {
return SimpleMapper.Map(this, new ManualTrigger()); 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> /// </summary>
public string? Condition { get; set; } public string? Condition { get; set; }
public static SchemaChangedRuleTriggerDto FromDomain(SchemaChangedTrigger trigger)
{
return SimpleMapper.Map(trigger, new SchemaChangedRuleTriggerDto());
}
public override RuleTrigger ToTrigger() public override RuleTrigger ToTrigger()
{ {
return SimpleMapper.Map(this, new SchemaChangedTrigger()); 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)] [LocalizedRange(1, 30)]
public int? NumDays { get; set; } public int? NumDays { get; set; }
public static UsageRuleTriggerDto FromDomain(UsageTrigger trigger)
{
return SimpleMapper.Map(trigger, new UsageRuleTriggerDto());
}
public override RuleTrigger ToTrigger() public override RuleTrigger ToTrigger()
{ {
return SimpleMapper.Map(this, new UsageTrigger()); 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.Areas.Api.Controllers.Schemas.Models.Fields;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Schemas.Models.Converters; 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> /// </summary>
public ReadonlyList<string>? UniqueFields { get; set; } 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> /// </summary>
public bool AllowDuplicates { get; set; } 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> /// </summary>
public BooleanFieldEditor Editor { get; set; } 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> /// </summary>
public ReadonlyList<DomainId>? SchemaIds { get; set; } 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> /// </summary>
public ReadonlyList<string>? UniqueFields { get; set; } 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> /// </summary>
public DateTimeCalculatedDefaultValue? CalculatedDefaultValue { get; set; } 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