Browse Source

Merge branch 'release/4.x' of github.com:Squidex/squidex

# Conflicts:
#	backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoIndexStorage.cs
#	backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/ILuceneTextIndexGrain.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndex.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndexGrain.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/AssetIndexStorage.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/FileIndexStorage.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/IIndexStorage.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/ITextIndexerState.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/InMemoryTextIndexerState.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
#	backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
#	backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs
#	backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/CachingTextIndexerStateTests.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/LuceneIndexFactory.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
#	backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs
pull/590/head
Sebastian 5 years ago
parent
commit
d6b5f056dc
  1. 1
      backend/i18n/source/backend_en.json
  2. 10
      backend/src/Migrations/Migrations/ConvertEventStore.cs
  3. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs
  4. 14
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs
  5. 25
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AggregateValidator.cs
  6. 35
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs
  9. 123
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoIndexStorage.cs
  10. 168
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs
  11. 49
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs
  12. 14
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs
  13. 61
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexState.cs
  14. 61
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs
  15. 16
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs
  16. 10
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs
  17. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  18. 18
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  19. 5
      backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs
  20. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs
  21. 12
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  22. 47
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs
  23. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs
  24. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexCommand.cs
  25. 28
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IIndex.cs
  26. 22
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/ILuceneTextIndexGrain.cs
  27. 150
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs
  28. 180
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs
  29. 53
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneExtensions.cs
  30. 69
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndex.cs
  31. 259
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndexGrain.cs
  32. 65
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/MultiLanguageAnalyzer.cs
  33. 113
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/AssetIndexStorage.cs
  34. 37
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/FileIndexStorage.cs
  35. 23
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/IIndexStorage.cs
  36. 69
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs
  37. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/ITextIndexerState.cs
  38. 38
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/InMemoryTextIndexerState.cs
  39. 12
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/TextContentState.cs
  40. 440
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  41. 57
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
  42. 176
      backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
  43. 2
      backend/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs
  44. 15
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  45. 4
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  46. 17
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs
  47. 9
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs
  48. 12
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs
  49. 2
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  50. 6
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  51. 2
      backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs
  52. 10
      backend/src/Squidex.Infrastructure.RabbitMq/CQRS/Events/RabbitMqEventConsumer.cs
  53. 5
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  54. 77
      backend/src/Squidex.Infrastructure/EventSourcing/CompoundEventConsumer.cs
  55. 193
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs
  56. 153
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs
  57. 4
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerState.cs
  58. 5
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs
  59. 42
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/WrapperSubscription.cs
  60. 30
      backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs
  61. 12
      backend/src/Squidex.Infrastructure/EventSourcing/IEventSubscription.cs
  62. 6
      backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs
  63. 97
      backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs
  64. 33
      backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs
  65. 67
      backend/src/Squidex.Infrastructure/Tasks/SingleThreadedDispatcher.cs
  66. 9
      backend/src/Squidex.Infrastructure/Translations/ResourcesLocalizer.cs
  67. 3
      backend/src/Squidex.Shared/Texts.it.resx
  68. 3
      backend/src/Squidex.Shared/Texts.nl.resx
  69. 3
      backend/src/Squidex.Shared/Texts.resx
  70. 21
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  71. 18
      backend/src/Squidex/Config/Domain/StoreServices.cs
  72. 21
      backend/src/Squidex/appsettings.json
  73. 1
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs
  74. 1
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventEnricherTests.cs
  75. 66
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs
  76. 50
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs
  77. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs
  78. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs
  79. 92
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/CachingTextIndexerStateTests.cs
  80. 56
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/DocValuesTests.cs
  81. 50
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/LuceneIndexFactory.cs
  82. 49
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TestStorages.cs
  83. 124
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs
  84. 41
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs
  85. 14
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_FS.cs
  86. 33
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs
  87. 18
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  88. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  89. 10
      backend/tests/Squidex.Infrastructure.Tests/Commands/CommandRequestTests.cs
  90. 131
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CompoundEventConsumerTests.cs
  91. 5
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs
  92. 198
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs
  93. 9
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs
  94. 26
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs
  95. 4
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs
  96. 67
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs
  97. 4
      backend/tests/Squidex.Infrastructure.Tests/Migrations/MigratorTests.cs
  98. 8
      backend/tests/Squidex.Infrastructure.Tests/MongoDb/MongoExtensionsTests.cs
  99. 56
      backend/tests/Squidex.Infrastructure.Tests/Tasks/SingleThreadedDispatcherTests.cs

1
backend/i18n/source/backend_en.json

@ -147,6 +147,7 @@
"contents.validation.characterCount": "Must have exactly {count} character(s).", "contents.validation.characterCount": "Must have exactly {count} character(s).",
"contents.validation.charactersBetween": "Must have between {min} and {max} character(s).", "contents.validation.charactersBetween": "Must have between {min} and {max} character(s).",
"contents.validation.duplicates": "Must not contain duplicate values.", "contents.validation.duplicates": "Must not contain duplicate values.",
"contents.validation.error": "Validation failed with internal error.",
"contents.validation.exactValue": "Must be exactly {value}.", "contents.validation.exactValue": "Must be exactly {value}.",
"contents.validation.extension": "Must be an allowed extension.", "contents.validation.extension": "Must be an allowed extension.",
"contents.validation.image": "Not an image.", "contents.validation.image": "Not an image.",

10
backend/src/Migrations/Migrations/ConvertEventStore.cs

@ -32,20 +32,20 @@ namespace Migrations.Migrations
var filter = Builders<BsonDocument>.Filter; var filter = Builders<BsonDocument>.Filter;
var writesBatches = new List<WriteModel<BsonDocument>>(); var writes = new List<WriteModel<BsonDocument>>();
async Task WriteAsync(WriteModel<BsonDocument>? model, bool force) async Task WriteAsync(WriteModel<BsonDocument>? model, bool force)
{ {
if (model != null) if (model != null)
{ {
writesBatches.Add(model); writes.Add(model);
} }
if (writesBatches.Count == 1000 || (force && writesBatches.Count > 0)) if (writes.Count == 1000 || (force && writes.Count > 0))
{ {
await collection.BulkWriteAsync(writesBatches); await collection.BulkWriteAsync(writes);
writesBatches.Clear(); writes.Clear();
} }
} }

9
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.ValidateContent.Validators; using Squidex.Domain.Apps.Core.ValidateContent.Validators;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
#pragma warning disable SA1028, IDE0004 // Code must not contain trailing whitespace #pragma warning disable SA1028, IDE0004 // Code must not contain trailing whitespace
@ -25,6 +26,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
private readonly PartitionResolver partitionResolver; private readonly PartitionResolver partitionResolver;
private readonly ValidationContext context; private readonly ValidationContext context;
private readonly IEnumerable<IValidatorsFactory> factories; private readonly IEnumerable<IValidatorsFactory> factories;
private readonly ISemanticLog log;
private readonly ConcurrentBag<ValidationError> errors = new ConcurrentBag<ValidationError>(); private readonly ConcurrentBag<ValidationError> errors = new ConcurrentBag<ValidationError>();
public IReadOnlyCollection<ValidationError> Errors public IReadOnlyCollection<ValidationError> Errors
@ -32,7 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
get { return errors; } get { return errors; }
} }
public ContentValidator(PartitionResolver partitionResolver, ValidationContext context, IEnumerable<IValidatorsFactory> factories) public ContentValidator(PartitionResolver partitionResolver, ValidationContext context, IEnumerable<IValidatorsFactory> factories, ISemanticLog log)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
Guard.NotNull(factories, nameof(factories)); Guard.NotNull(factories, nameof(factories));
@ -40,6 +42,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
this.context = context; this.context = context;
this.factories = factories; this.factories = factories;
this.log = log;
this.partitionResolver = partitionResolver; this.partitionResolver = partitionResolver;
} }
@ -72,7 +75,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
{ {
Guard.NotNull(data, nameof(data)); Guard.NotNull(data, nameof(data));
var validator = new AggregateValidator(CreateContentValidators()); var validator = new AggregateValidator(CreateContentValidators(), log);
return validator.ValidateAsync(data, context, AddError); return validator.ValidateAsync(data, context, AddError);
} }
@ -108,7 +111,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
return new AggregateValidator( return new AggregateValidator(
CreateFieldValidators(field) CreateFieldValidators(field)
.Union(Enumerable.Repeat( .Union(Enumerable.Repeat(
new ObjectValidator<IJsonValue>(fieldsValidators, isPartial, typeName), 1))); new ObjectValidator<IJsonValue>(fieldsValidators, isPartial, typeName), 1)), log);
} }
private IValidator CreateFieldValidator(IField field) private IValidator CreateFieldValidator(IField field)

14
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs

@ -47,13 +47,17 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
ValidationMode mode = ValidationMode.Default) ValidationMode mode = ValidationMode.Default)
{ {
AppId = appId; AppId = appId;
ContentId = contentId; ContentId = contentId;
IsOptional = isOptional;
Mode = mode; Mode = mode;
Path = path;
Schema = schema; Schema = schema;
SchemaId = schemaId; SchemaId = schemaId;
IsOptional = isOptional;
Path = path;
} }
public ValidationContext Optimized(bool isOptimized = true) public ValidationContext Optimized(bool isOptimized = true)
@ -68,14 +72,14 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
return Clone(Path, IsOptional, mode); return Clone(Path, IsOptional, mode);
} }
public ValidationContext Optional(bool isOptional) public ValidationContext Optional(bool fieldIsOptional)
{ {
if (IsOptional == isOptional) if (IsOptional == fieldIsOptional)
{ {
return this; return this;
} }
return Clone(Path, isOptional, Mode); return Clone(Path, fieldIsOptional, Mode);
} }
public ValidationContext Nested(string property) public ValidationContext Nested(string property)

25
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AggregateValidator.cs

@ -5,29 +5,44 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
public sealed class AggregateValidator : IValidator public sealed class AggregateValidator : IValidator
{ {
private readonly IValidator[]? validators; private readonly IValidator[]? validators;
private readonly ISemanticLog log;
public AggregateValidator(IEnumerable<IValidator>? validators) public AggregateValidator(IEnumerable<IValidator>? validators, ISemanticLog log)
{ {
this.validators = validators?.ToArray(); this.validators = validators?.ToArray();
this.log = log;
} }
public Task ValidateAsync(object? value, ValidationContext context, AddError addError) public async Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{ {
if (validators?.Length > 0) try
{ {
return Task.WhenAll(validators.Select(x => x.ValidateAsync(value, context, addError))); if (validators?.Length > 0)
{
await Task.WhenAll(validators.Select(x => x.ValidateAsync(value, context, addError)));
}
} }
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "validateField")
.WriteProperty("status", "Failed"));
return Task.CompletedTask; addError(context.Path, T.Get("contents.validation.error"));
}
} }
} }
} }

35
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs

@ -17,24 +17,24 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
public sealed class FieldValidator : IValidator public sealed class FieldValidator : IValidator
{ {
private readonly IValidator[] validators; private readonly IValidator[]? validators;
private readonly IField field; private readonly IField field;
public FieldValidator(IEnumerable<IValidator> validators, IField field) public FieldValidator(IEnumerable<IValidator>? validators, IField field)
{ {
Guard.NotNull(field, nameof(field)); Guard.NotNull(field, nameof(field));
this.validators = validators.ToArray(); this.validators = validators?.ToArray();
this.field = field; this.field = field;
} }
public async Task ValidateAsync(object? value, ValidationContext context, AddError addError) public async Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{ {
var typedValue = value;
try try
{ {
var typedValue = value;
if (value is IJsonValue jsonValue) if (value is IJsonValue jsonValue)
{ {
if (jsonValue.Type == JsonValueType.Null) if (jsonValue.Type == JsonValueType.Null)
@ -55,22 +55,23 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
} }
} }
} }
if (validators?.Length > 0)
{
var tasks = new List<Task>();
foreach (var validator in validators)
{
tasks.Add(validator.ValidateAsync(typedValue, context, addError));
}
await Task.WhenAll(tasks);
}
} }
catch catch
{ {
addError(context.Path, T.Get("contents.validation.invalid")); addError(context.Path, T.Get("contents.validation.invalid"));
return;
}
if (validators?.Length > 0)
{
var tasks = new List<Task>();
foreach (var validator in validators)
{
tasks.Add(validator.ValidateAsync(typedValue, context, addError));
}
await Task.WhenAll(tasks);
} }
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs

@ -54,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
using (Profiler.TraceMethod<MongoAssetFolderRepository>()) using (Profiler.TraceMethod<MongoAssetFolderRepository>())
{ {
await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipelineAsync(x => callback(Map(x), x.Version), ct); await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipedAsync(x => callback(Map(x), x.Version), ct);
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs

@ -54,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
using (Profiler.TraceMethod<MongoAssetRepository>()) using (Profiler.TraceMethod<MongoAssetRepository>())
{ {
await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipelineAsync(x => callback(Map(x), x.Version), ct); await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipedAsync(x => callback(Map(x), x.Version), ct);
} }
} }

123
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoIndexStorage.cs

@ -1,123 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Lucene.Net.Index;
using Lucene.Net.Store;
using MongoDB.Driver.GridFS;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene;
using Squidex.Infrastructure;
using LuceneDirectory = Lucene.Net.Store.Directory;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{
public sealed class MongoIndexStorage : IIndexStorage
{
private readonly IGridFSBucket<string> bucket;
public MongoIndexStorage(IGridFSBucket<string> bucket)
{
Guard.NotNull(bucket, nameof(bucket));
this.bucket = bucket;
}
public async Task<LuceneDirectory> CreateDirectoryAsync(DomainId ownerId)
{
var fileId = $"index_{ownerId}";
var directoryInfo = new DirectoryInfo(Path.Combine(Path.GetTempPath(), fileId));
if (directoryInfo.Exists)
{
directoryInfo.Delete(true);
}
directoryInfo.Create();
try
{
using (var stream = await bucket.OpenDownloadStreamAsync(fileId))
{
using (var zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, true))
{
foreach (var entry in zipArchive.Entries)
{
var file = new FileInfo(Path.Combine(directoryInfo.FullName, entry.Name));
using (var entryStream = entry.Open())
{
using (var fileStream = file.OpenWrite())
{
await entryStream.CopyToAsync(fileStream);
}
}
}
}
}
}
catch (GridFSFileNotFoundException)
{
}
var directory = FSDirectory.Open(directoryInfo);
return directory;
}
public async Task WriteAsync(LuceneDirectory directory, SnapshotDeletionPolicy snapshotter)
{
var directoryInfo = ((FSDirectory)directory).Directory;
var commit = snapshotter.Snapshot();
try
{
var fileId = directoryInfo.Name;
try
{
await bucket.DeleteAsync(fileId);
}
catch (GridFSFileNotFoundException)
{
}
using (var stream = await bucket.OpenUploadStreamAsync(fileId, fileId))
{
using (var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
foreach (var fileName in commit.FileNames)
{
var file = new FileInfo(Path.Combine(directoryInfo.FullName, fileName));
using (var fileStream = file.OpenRead())
{
var entry = zipArchive.CreateEntry(fileStream.Name);
using (var entryStream = entry.Open())
{
await fileStream.CopyToAsync(entryStream);
}
}
}
}
}
}
finally
{
snapshotter.Release(commit);
}
}
public Task ClearAsync()
{
return bucket.DropAsync();
}
}
}

168
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs

@ -0,0 +1,168 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{
public sealed class MongoTextIndex : MongoRepositoryBase<MongoTextIndexEntity>, ITextIndex
{
private static readonly List<DomainId> EmptyResults = new List<DomainId>();
public MongoTextIndex(IMongoDatabase database, bool setup = false)
: base(database, setup)
{
}
protected override Task SetupCollectionAsync(IMongoCollection<MongoTextIndexEntity> collection, CancellationToken ct = default)
{
return collection.Indexes.CreateManyAsync(new[]
{
new CreateIndexModel<MongoTextIndexEntity>(
Index
.Text("t.t")
.Ascending(x => x.AppId)
.Ascending(x => x.ServeAll)
.Ascending(x => x.ServePublished)
.Ascending(x => x.SchemaId))
}, ct);
}
protected override string CollectionName()
{
return "TextIndex";
}
public Task ExecuteAsync(params IndexCommand[] commands)
{
var writes = new List<WriteModel<MongoTextIndexEntity>>(commands.Length);
foreach (var command in commands)
{
switch (command)
{
case DeleteIndexEntry delete:
writes.Add(
new DeleteOneModel<MongoTextIndexEntity>(
Filter.Eq(x => x.DocId, command.DocId)));
break;
case UpdateIndexEntry update:
writes.Add(
new UpdateOneModel<MongoTextIndexEntity>(
Filter.Eq(x => x.DocId, command.DocId),
Update
.Set(x => x.ServeAll, update.ServeAll)
.Set(x => x.ServePublished, update.ServePublished)));
break;
case UpsertIndexEntry upsert when upsert.Texts.Count > 0:
writes.Add(
new ReplaceOneModel<MongoTextIndexEntity>(
Filter.Eq(x => x.DocId, command.DocId),
new MongoTextIndexEntity
{
DocId = upsert.DocId,
ContentId = upsert.ContentId,
SchemaId = upsert.SchemaId.Id,
ServeAll = upsert.ServeAll,
ServePublished = upsert.ServePublished,
Texts = upsert.Texts.Select(x => new MongoTextIndexEntityText { Text = x.Value }).ToList(),
AppId = upsert.AppId.Id
})
{
IsUpsert = true
});
break;
}
}
if (writes.Count == 0)
{
return Task.CompletedTask;
}
return Collection.BulkWriteAsync(writes);
}
public async Task<List<DomainId>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return EmptyResults;
}
if (filter == null)
{
return await SearchByAppAsync(queryText, app, scope, 2000);
}
else if (filter.Must)
{
return await SearchBySchemaAsync(queryText, app, filter, scope, 2000);
}
else
{
var (bySchema, byApp) =
await AsyncHelper.WhenAll(
SearchBySchemaAsync(queryText, app, filter, scope, 1000),
SearchByAppAsync(queryText, app, scope, 1000));
return bySchema.Union(byApp).Distinct().ToList();
}
}
private async Task<List<DomainId>> SearchBySchemaAsync(string queryText, IAppEntity app, SearchFilter filter, SearchScope scope, int limit)
{
var bySchema =
await Collection.Find(
Filter.And(
Filter.Eq(x => x.AppId, app.Id),
Filter.In(x => x.SchemaId, filter.SchemaIds),
Filter_ByScope(scope),
Filter.Text(queryText)))
.Only(x => x.ContentId).Limit(limit)
.ToListAsync();
return bySchema.Select(x => DomainId.Create(x["_ci"].AsString)).Distinct().ToList();
}
private async Task<List<DomainId>> SearchByAppAsync(string queryText, IAppEntity app, SearchScope scope, int limit)
{
var bySchema =
await Collection.Find(
Filter.And(
Filter.Eq(x => x.AppId, app.Id),
Filter.Exists(x => x.SchemaId),
Filter_ByScope(scope),
Filter.Text(queryText)))
.Only(x => x.ContentId).Limit(limit)
.ToListAsync();
return bySchema.Select(x => DomainId.Create(x["_ci"].AsString)).Distinct().ToList();
}
private static FilterDefinition<MongoTextIndexEntity> Filter_ByScope(SearchScope scope)
{
if (scope == SearchScope.All)
{
return Filter.Eq(x => x.ServeAll, true);
}
else
{
return Filter.Eq(x => x.ServePublished, true);
}
}
}
}

49
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs

@ -0,0 +1,49 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{
public sealed class MongoTextIndexEntity
{
[BsonId]
[BsonElement]
[BsonRepresentation(BsonType.String)]
public string DocId { get; set; }
[BsonRequired]
[BsonElement("_ci")]
[BsonRepresentation(BsonType.String)]
public DomainId ContentId { get; set; }
[BsonRequired]
[BsonElement("_ai")]
[BsonRepresentation(BsonType.String)]
public DomainId AppId { get; set; }
[BsonRequired]
[BsonElement("_si")]
[BsonRepresentation(BsonType.String)]
public DomainId SchemaId { get; set; }
[BsonRequired]
[BsonElement("fa")]
public bool ServeAll { get; set; }
[BsonRequired]
[BsonElement("fp")]
public bool ServePublished { get; set; }
[BsonIgnoreIfNull]
[BsonElement("t")]
public List<MongoTextIndexEntityText> Texts { get; set; }
}
}

14
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Assets.cs → backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs

@ -5,10 +5,18 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Text using MongoDB.Bson.Serialization.Attributes;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{ {
public class TextIndexerTests_Assets : TextIndexerTestsBase public sealed class MongoTextIndexEntityText
{ {
public override IIndexerFactory Factory { get; } = new LuceneIndexFactory(TestStorages.Assets()); [BsonRequired]
[BsonElement("t")]
public string Text { get; set; }
[BsonIgnoreIfNull]
[BsonElement("language")]
public string Language { get; set; }
} }
} }

61
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexState.cs

@ -1,61 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson.Serialization.Attributes;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{
public sealed class MongoTextIndexState
{
[BsonId]
[BsonElement]
public DomainId DocumentId { get; set; }
[BsonRequired]
[BsonElement]
public DomainId ContentId { get; set; }
[BsonRequired]
[BsonElement("c")]
public string DocIdCurrent { get; set; }
[BsonRequired]
[BsonElement("n")]
public string? DocIdNew { get; set; }
[BsonRequired]
[BsonElement("p")]
public string? DocIdForPublished { get; set; }
public MongoTextIndexState()
{
}
public MongoTextIndexState(DomainId documentId, TextContentState state)
{
DocumentId = documentId;
ContentId = state.ContentId;
DocIdNew = state.DocIdNew;
DocIdCurrent = state.DocIdCurrent;
DocIdForPublished = state.DocIdForPublished;
}
public TextContentState ToState()
{
return new TextContentState
{
ContentId = ContentId,
DocIdNew = DocIdNew,
DocIdCurrent = DocIdCurrent,
DocIdForPublished = DocIdForPublished
};
}
}
}

61
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs

@ -5,7 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Contents.Text.State; using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -13,8 +16,25 @@ using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{ {
public sealed class MongoTextIndexerState : MongoRepositoryBase<MongoTextIndexState>, ITextIndexerState public sealed class MongoTextIndexerState : MongoRepositoryBase<TextContentState>, ITextIndexerState
{ {
static MongoTextIndexerState()
{
BsonClassMap.RegisterClassMap<TextContentState>(cm =>
{
cm.MapIdField(x => x.UniqueContentId);
cm.MapProperty(x => x.DocIdCurrent)
.SetElementName("c");
cm.MapProperty(x => x.DocIdNew)
.SetElementName("n").SetIgnoreIfNull(true);
cm.MapProperty(x => x.DocIdForPublished)
.SetElementName("p").SetIgnoreIfNull(true);
});
}
public MongoTextIndexerState(IMongoDatabase database, bool setup = false) public MongoTextIndexerState(IMongoDatabase database, bool setup = false)
: base(database, setup) : base(database, setup)
{ {
@ -25,28 +45,37 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
return "TextIndexerState"; return "TextIndexerState";
} }
public async Task<TextContentState?> GetAsync(DomainId appId, DomainId contentId) public async Task<Dictionary<DomainId, TextContentState>> GetAsync(HashSet<DomainId> ids)
{ {
var documentId = DomainId.Combine(appId, contentId).ToString(); var entities = await Collection.Find(Filter.In(x => x.UniqueContentId, ids)).ToListAsync();
var result = await Collection.Find(x => x.DocumentId == documentId).FirstOrDefaultAsync()!;
return result?.ToState(); return entities.ToDictionary(x => x.UniqueContentId);
} }
public Task RemoveAsync(DomainId appId, DomainId contentId) public Task SetAsync(List<TextContentState> updates)
{ {
var documentId = DomainId.Combine(appId, contentId).ToString(); var writes = new List<WriteModel<TextContentState>>();
return Collection.DeleteOneAsync(x => x.DocumentId == documentId); foreach (var update in updates)
} {
if (update.IsDeleted)
public Task SetAsync(DomainId appId, TextContentState state) {
{ writes.Add(
var documentId = DomainId.Combine(appId, state.ContentId).ToString(); new DeleteOneModel<TextContentState>(
var document = new MongoTextIndexState(documentId, state); Filter.Eq(x => x.UniqueContentId, update.UniqueContentId)));
}
else
{
writes.Add(
new ReplaceOneModel<TextContentState>(
Filter.Eq(x => x.UniqueContentId, update.UniqueContentId), update)
{
IsUpsert = true
});
}
}
return Collection.ReplaceOneAsync(x => x.DocumentId == documentId, document, UpsertReplace); return Collection.BulkWriteAsync(writes);
} }
} }
} }

16
backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization;
@ -64,9 +65,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
} }
} }
public Task InsertAsync(HistoryEvent item) public async Task InsertManyAsync(IEnumerable<HistoryEvent> historyEvents)
{ {
return Collection.ReplaceOneAsync(x => x.Id == item.Id, item, UpsertReplace); var writes = historyEvents
.Select(x =>
new ReplaceOneModel<HistoryEvent>(Filter.Eq(y => y.Id, x.Id), x)
{
IsUpsert = true
})
.ToList();
if (writes.Count > 0)
{
await Collection.BulkWriteAsync(writes);
}
} }
public Task RemoveAsync(DomainId appId) public Task RemoveAsync(DomainId appId)

10
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs

@ -45,16 +45,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
this.log = log; this.log = log;
} }
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
public async Task On(Envelope<IEvent> @event) public async Task On(Envelope<IEvent> @event)
{ {
if (!emailSender.IsActive) if (!emailSender.IsActive)

3
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -9,14 +9,13 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.UsageTracking; using Squidex.Infrastructure.UsageTracking;
#pragma warning disable CS0649 #pragma warning disable CS0649
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public partial class AssetUsageTracker : IAssetUsageTracker, IEventConsumer public partial class AssetUsageTracker : IAssetUsageTracker
{ {
private const string CounterTotalCount = "TotalAssets"; private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize"; private const string CounterTotalSize = "TotalSize";

18
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -14,26 +14,26 @@ using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public partial class AssetUsageTracker public partial class AssetUsageTracker : IEventConsumer
{ {
public string Name public int BatchSize
{ {
get { return GetType().Name; } get { return 1000; }
} }
public string EventsFilter public int BatchDelay
{ {
get { return "^asset-"; } get { return 1000; }
} }
public bool Handles(StoredEvent @event) public string Name
{ {
return true; get { return GetType().Name; }
} }
public Task ClearAsync() public string EventsFilter
{ {
return Task.CompletedTask; get { return "^asset-"; }
} }
public Task On(Envelope<IEvent> @event) public Task On(Envelope<IEvent> @event)

5
backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs

@ -57,11 +57,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
folderDeletedType = typeNameRegistry.GetName<AssetFolderDeleted>(); folderDeletedType = typeNameRegistry.GetName<AssetFolderDeleted>();
} }
public Task ClearAsync()
{
return Task.CompletedTask;
}
public bool Handles(StoredEvent @event) public bool Handles(StoredEvent @event)
{ {
return @event.Data.Type == folderDeletedType; return @event.Data.Type == folderDeletedType;

2
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs

@ -224,7 +224,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
log.LogError(ex, logContext, (ctx, w) => log.LogError(ex, logContext, (ctx, w) =>
{ {
w.WriteProperty("action", "retore"); w.WriteProperty("action", "restore");
w.WriteProperty("status", "failed"); w.WriteProperty("status", "failed");
w.WriteProperty("operationId", ctx.jobId); w.WriteProperty("operationId", ctx.jobId);
w.WriteProperty("url", ctx.jobUrl); w.WriteProperty("url", ctx.jobUrl);

12
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs

@ -18,6 +18,7 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
#pragma warning disable IDE0016 // Use 'throw' expression #pragma warning disable IDE0016 // Use 'throw' expression
@ -34,6 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}; };
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly ISemanticLog log;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IEnumerable<IValidatorsFactory> factories; private readonly IEnumerable<IValidatorsFactory> factories;
private ISchemaEntity schema; private ISchemaEntity schema;
@ -41,11 +43,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
private ContentCommand command; private ContentCommand command;
private ValidationContext validationContext; private ValidationContext validationContext;
public ContentOperationContext(IAppProvider appProvider, IEnumerable<IValidatorsFactory> factories, IScriptEngine scriptEngine) public ContentOperationContext(IAppProvider appProvider, IEnumerable<IValidatorsFactory> factories, IScriptEngine scriptEngine, ISemanticLog log)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.factories = factories; this.factories = factories;
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.log = log;
} }
public ISchemaEntity Schema public ISchemaEntity Schema
@ -85,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task ValidateInputAsync(NamedContentData data) public async Task ValidateInputAsync(NamedContentData data)
{ {
var validator = new ContentValidator(app.PartitionResolver(), validationContext, factories); var validator = new ContentValidator(app.PartitionResolver(), validationContext, factories, log);
await validator.ValidateInputAsync(data); await validator.ValidateInputAsync(data);
@ -94,7 +98,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task ValidateInputPartialAsync(NamedContentData data) public async Task ValidateInputPartialAsync(NamedContentData data)
{ {
var validator = new ContentValidator(app.PartitionResolver(), validationContext, factories); var validator = new ContentValidator(app.PartitionResolver(), validationContext, factories, log);
await validator.ValidateInputPartialAsync(data); await validator.ValidateInputPartialAsync(data);
@ -103,7 +107,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task ValidateContentAsync(NamedContentData data) public async Task ValidateContentAsync(NamedContentData data)
{ {
var validator = new ContentValidator(app.PartitionResolver(), validationContext, factories); var validator = new ContentValidator(app.PartitionResolver(), validationContext, factories, log);
await validator.ValidateContentAsync(data); await validator.ValidateContentAsync(data);

47
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs

@ -18,14 +18,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class ElasticSearchTextIndex : ITextIndex public sealed class ElasticSearchTextIndex : ITextIndex
{ {
private const string IndexName = "contents";
private readonly ElasticLowLevelClient client; private readonly ElasticLowLevelClient client;
private readonly string indexName;
private readonly bool waitForTesting;
public ElasticSearchTextIndex() public ElasticSearchTextIndex(string configurationString, string indexName, bool waitForTesting = false)
{ {
var config = new ConnectionConfiguration(new Uri("http://localhost:9200")); Guard.NotNull(configurationString, nameof(configurationString));
Guard.NotNull(indexName, nameof(indexName));
var config = new ConnectionConfiguration(new Uri(configurationString));
client = new ElasticLowLevelClient(config); client = new ElasticLowLevelClient(config);
this.indexName = indexName;
this.waitForTesting = waitForTesting;
} }
public Task ClearAsync() public Task ClearAsync()
@ -33,14 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task ExecuteAsync(NamedId<DomainId> appId, NamedId<DomainId> schemaId, params IndexCommand[] commands) public async Task ExecuteAsync(params IndexCommand[] commands)
{ {
foreach (var command in commands) foreach (var command in commands)
{ {
switch (command) switch (command)
{ {
case UpsertIndexEntry upsert: case UpsertIndexEntry upsert:
await UpsertAsync(appId, schemaId, upsert); await UpsertAsync(upsert);
break; break;
case UpdateIndexEntry update: case UpdateIndexEntry update:
await UpdateAsync(update); await UpdateAsync(update);
@ -50,23 +58,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
break; break;
} }
} }
if (waitForTesting)
{
await Task.Delay(1000);
}
} }
private async Task UpsertAsync(NamedId<DomainId> appId, NamedId<DomainId> schemaId, UpsertIndexEntry upsert) private async Task UpsertAsync(UpsertIndexEntry upsert)
{ {
var data = new var data = new
{ {
appId = appId.Id, appId = upsert.AppId.Id,
appName = appId.Name, appName = upsert.AppId.Name,
contentId = upsert.ContentId, contentId = upsert.ContentId,
schemaId = schemaId.Id, schemaId = upsert.SchemaId.Id,
schemaName = schemaId.Name, schemaName = upsert.SchemaId.Name,
serveAll = upsert.ServeAll, serveAll = upsert.ServeAll,
servePublished = upsert.ServePublished, servePublished = upsert.ServePublished,
texts = upsert.Texts texts = upsert.Texts
}; };
var result = await client.IndexAsync<StringResponse>(IndexName, upsert.DocId, CreatePost(data)); var result = await client.IndexAsync<StringResponse>(indexName, upsert.DocId, CreatePost(data));
if (!result.Success) if (!result.Success)
{ {
@ -80,12 +93,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
{ {
doc = new doc = new
{ {
update.ServeAll, serveAll = update.ServeAll,
update.ServePublished servePublished = update.ServePublished
} }
}; };
var result = await client.UpdateAsync<StringResponse>(IndexName, update.DocId, CreatePost(data)); var result = await client.UpdateAsync<StringResponse>(indexName, update.DocId, CreatePost(data));
if (!result.Success) if (!result.Success)
{ {
@ -95,7 +108,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
private Task DeleteAsync(DeleteIndexEntry delete) private Task DeleteAsync(DeleteIndexEntry delete)
{ {
return client.DeleteAsync<StringResponse>(IndexName, delete.DocId); return client.DeleteAsync<StringResponse>(indexName, delete.DocId);
} }
private static PostData CreatePost<T>(T data) private static PostData CreatePost<T>(T data)
@ -155,7 +168,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
{ {
var bySchema = new var bySchema = new
{ {
term = new Dictionary<string, object> terms = new Dictionary<string, object>
{ {
["schemaId.keyword"] = filter.SchemaIds ["schemaId.keyword"] = filter.SchemaIds
} }
@ -171,7 +184,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
} }
} }
var result = await client.SearchAsync<DynamicResponse>(IndexName, CreatePost(query)); var result = await client.SearchAsync<DynamicResponse>(indexName, CreatePost(query));
if (!result.Success) if (!result.Success)
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs

@ -18,6 +18,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
Task ClearAsync(); Task ClearAsync();
Task ExecuteAsync(NamedId<DomainId> appId, NamedId<DomainId> schemaId, params IndexCommand[] commands); Task ExecuteAsync(params IndexCommand[] commands);
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexCommand.cs

@ -5,10 +5,16 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text namespace Squidex.Domain.Apps.Entities.Contents.Text
{ {
public abstract class IndexCommand public abstract class IndexCommand
{ {
public NamedId<DomainId> AppId { get; set; }
public NamedId<DomainId> SchemaId { get; set; }
public string DocId { get; set; } public string DocId { get; set; }
} }
} }

28
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IIndex.cs

@ -1,28 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Lucene.Net.Analysis;
using Lucene.Net.Index;
using Lucene.Net.Search;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public interface IIndex
{
Analyzer? Analyzer { get; }
IndexReader? Reader { get; }
IndexSearcher? Searcher { get; }
IndexWriter Writer { get; }
void EnsureReader();
void MarkStale();
}
}

22
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/ILuceneTextIndexGrain.cs

@ -1,22 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public interface ILuceneTextIndexGrain : IGrainWithStringKey
{
Task IndexAsync(NamedId<DomainId> schemaId, Immutable<IndexCommand[]> updates);
Task<List<DomainId>> SearchAsync(string queryText, SearchFilter? filter, SearchContext context);
}
}

150
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs

@ -1,150 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed partial class IndexManager : DisposableObjectBase
{
private readonly Dictionary<DomainId, IndexHolder> indices = new Dictionary<DomainId, IndexHolder>();
private readonly SemaphoreSlim lockObject = new SemaphoreSlim(1);
private readonly IIndexStorage indexStorage;
private readonly ISemanticLog log;
public IndexManager(IIndexStorage indexStorage, ISemanticLog log)
{
Guard.NotNull(indexStorage, nameof(indexStorage));
Guard.NotNull(log, nameof(log));
this.indexStorage = indexStorage;
this.log = log;
}
protected override void DisposeObject(bool disposing)
{
if (disposing)
{
ReleaseAllAsync().Wait();
}
}
public Task ClearAsync()
{
return indexStorage.ClearAsync();
}
public async Task<IIndex> AcquireAsync(DomainId ownerId)
{
IndexHolder? indexHolder;
try
{
await lockObject.WaitAsync();
if (indices.TryGetValue(ownerId, out indexHolder))
{
log.LogWarning(w => w
.WriteProperty("message", "Unreleased index found.")
.WriteProperty("ownerId", ownerId.ToString()));
await CommitInternalAsync(indexHolder, true);
}
var directory = await indexStorage.CreateDirectoryAsync(ownerId);
indexHolder = new IndexHolder(ownerId, directory);
indices[ownerId] = indexHolder;
}
finally
{
lockObject.Release();
}
return indexHolder;
}
public async Task ReleaseAsync(IIndex index)
{
Guard.NotNull(index, nameof(index));
var indexHolder = (IndexHolder)index;
try
{
await lockObject.WaitAsync();
indexHolder.Dispose();
indices.Remove(indexHolder.Id);
}
finally
{
lockObject.Release();
}
await CommitInternalAsync(indexHolder, true);
}
public Task CommitAsync(IIndex index)
{
Guard.NotNull(index, nameof(index));
return CommitInternalAsync(index, false);
}
private async Task CommitInternalAsync(IIndex index, bool dispose)
{
if (index is IndexHolder holder)
{
if (dispose)
{
holder.Dispose();
}
else
{
holder.Commit();
}
await indexStorage.WriteAsync(holder.Directory, holder.Snapshotter);
}
}
private async Task ReleaseAllAsync()
{
var current = indices.Values.ToList();
try
{
await lockObject.WaitAsync();
indices.Clear();
}
finally
{
lockObject.Release();
}
if (current.Count > 0)
{
log.LogWarning(w => w
.WriteProperty("message", "Unreleased indices found.")
.WriteProperty("count", indices.Count));
foreach (var index in current)
{
await CommitInternalAsync(index, true);
}
}
}
}
}

180
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs

@ -1,180 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Lucene.Net.Analysis;
using Lucene.Net.Index;
using Lucene.Net.Search;
using Lucene.Net.Store;
using Lucene.Net.Util;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed partial class IndexManager
{
private sealed class IndexHolder : IDisposable, IIndex
{
private const LuceneVersion Version = LuceneVersion.LUCENE_48;
private static readonly MergeScheduler MergeScheduler = new ConcurrentMergeScheduler();
private static readonly Analyzer SharedAnalyzer = new MultiLanguageAnalyzer(Version);
private readonly SnapshotDeletionPolicy snapshotter = new SnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy());
private readonly Directory directory;
private IndexWriter indexWriter;
private IndexSearcher? indexSearcher;
private DirectoryReader? indexReader;
private bool isDisposed;
public Analyzer Analyzer
{
get { return SharedAnalyzer; }
}
public SnapshotDeletionPolicy Snapshotter
{
get { return snapshotter; }
}
public Directory Directory
{
get { return directory; }
}
public IndexWriter Writer
{
get
{
ThrowIfReleased();
if (indexWriter == null)
{
throw new InvalidOperationException("Index writer has not been created yet. Call Open()");
}
return indexWriter;
}
}
public IndexReader? Reader
{
get
{
ThrowIfReleased();
return indexReader;
}
}
public IndexSearcher? Searcher
{
get
{
ThrowIfReleased();
return indexSearcher;
}
}
public DomainId Id { get; }
public IndexHolder(DomainId id, Directory directory)
{
Id = id;
this.directory = directory;
RecreateIndexWriter();
if (indexWriter.NumDocs > 0)
{
EnsureReader();
}
}
public void Dispose()
{
if (!isDisposed)
{
indexReader?.Dispose();
indexReader = null;
indexWriter.Dispose();
isDisposed = true;
}
}
private IndexWriter RecreateIndexWriter()
{
var config = new IndexWriterConfig(Version, Analyzer)
{
IndexDeletionPolicy = snapshotter,
MergePolicy = new TieredMergePolicy(),
MergeScheduler = MergeScheduler
};
indexWriter = new IndexWriter(directory, config);
MarkStale();
return indexWriter;
}
public void EnsureReader()
{
ThrowIfReleased();
if (indexReader == null && indexWriter != null)
{
indexReader = indexWriter.GetReader(true);
indexSearcher = new IndexSearcher(indexReader);
}
}
public void MarkStale()
{
ThrowIfReleased();
MarkStaleInternal();
}
private void MarkStaleInternal()
{
if (indexReader != null)
{
indexReader.Dispose();
indexReader = null;
indexSearcher = null;
}
}
internal void Commit()
{
try
{
MarkStaleInternal();
indexWriter.Commit();
}
catch (OutOfMemoryException)
{
RecreateIndexWriter();
throw;
}
}
private void ThrowIfReleased()
{
if (indexWriter == null)
{
throw new InvalidOperationException("Index is already released or not open yet.");
}
}
}
}
}

53
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneExtensions.cs

@ -1,53 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Lucene.Net.Index;
using Lucene.Net.Util;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public static class LuceneExtensions
{
public static BytesRef GetBinaryValue(this IndexReader? reader, string field, int docId, BytesRef? result = null)
{
if (result != null)
{
Array.Clear(result.Bytes, 0, result.Bytes.Length);
}
else
{
result = new BytesRef();
}
if (reader == null || docId < 0)
{
return result;
}
var leaves = reader.Leaves;
if (leaves.Count == 1)
{
var docValues = leaves[0].AtomicReader.GetBinaryDocValues(field);
docValues.Get(docId, result);
}
else if (leaves.Count > 1)
{
var subIndex = ReaderUtil.SubIndex(docId, leaves);
var subLeave = leaves[subIndex];
var subValues = subLeave.AtomicReader.GetBinaryDocValues(field);
subValues.Get(docId - subLeave.DocBase, result);
}
return result;
}
}
}

69
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndex.cs

@ -1,69 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed class LuceneTextIndex : ITextIndex
{
private readonly IGrainFactory grainFactory;
private readonly IndexManager indexManager;
public LuceneTextIndex(IGrainFactory grainFactory, IndexManager indexManager)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(indexManager, nameof(indexManager));
this.grainFactory = grainFactory;
this.indexManager = indexManager;
}
public Task ClearAsync()
{
return indexManager.ClearAsync();
}
public async Task<List<DomainId>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return null;
}
var index = grainFactory.GetGrain<ILuceneTextIndexGrain>(app.Id.ToString());
using (Profiler.TraceMethod<LuceneTextIndex>())
{
var context = CreateContext(app, scope);
return await index.SearchAsync(queryText, filter, context);
}
}
private static SearchContext CreateContext(IAppEntity app, SearchScope scope)
{
var languages = new HashSet<string>(app.LanguagesConfig.AllKeys);
return new SearchContext { Languages = languages, Scope = scope };
}
public Task ExecuteAsync(NamedId<DomainId> appId, NamedId<DomainId> schemaId, params IndexCommand[] commands)
{
var index = grainFactory.GetGrain<ILuceneTextIndexGrain>(appId.Id.ToString());
return index.IndexAsync(schemaId, commands.AsImmutable());
}
}
}

259
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndexGrain.cs

@ -1,259 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed class LuceneTextIndexGrain : GrainOfString, ILuceneTextIndexGrain
{
private const LuceneVersion Version = LuceneVersion.LUCENE_48;
private const int MaxResults = 2000;
private const int MaxUpdates = 400;
private const string MetaId = "_id";
private const string MetaFor = "_fd";
private const string MetaContentId = "_cid";
private const string MetaSchemaId = "_si";
private const string MetaSchemaName = "_sn";
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(10);
private static readonly string[] Invariant = { InvariantPartitioning.Key };
private readonly IndexManager indexManager;
private IDisposable? timer;
private IIndex index;
private QueryParser? queryParser;
private HashSet<string>? currentLanguages;
private int updates;
public LuceneTextIndexGrain(IndexManager indexManager)
{
Guard.NotNull(indexManager, nameof(indexManager));
this.indexManager = indexManager;
}
public override async Task OnDeactivateAsync()
{
if (index != null)
{
await CommitAsync();
await indexManager.ReleaseAsync(index);
}
}
protected override async Task OnActivateAsync(string key)
{
index = await indexManager.AcquireAsync(key);
}
public Task<List<DomainId>> SearchAsync(string queryText, SearchFilter? filter, SearchContext context)
{
var result = new List<DomainId>();
if (!string.IsNullOrWhiteSpace(queryText))
{
index.EnsureReader();
if (index.Searcher != null)
{
var query = BuildQuery(queryText, filter, context);
var hits = index.Searcher.Search(query, MaxResults).ScoreDocs;
if (hits.Length > 0)
{
var buffer = new BytesRef(2);
var found = new HashSet<DomainId>();
foreach (var hit in hits)
{
var forValue = index.Reader.GetBinaryValue(MetaFor, hit.Doc, buffer);
if (context.Scope == SearchScope.All && forValue.Bytes[0] != 1)
{
continue;
}
if (context.Scope == SearchScope.Published && forValue.Bytes[1] != 1)
{
continue;
}
var document = index.Searcher.Doc(hit.Doc);
var idString = document?.Get(MetaContentId);
if (idString != null)
{
if (found.Add(idString))
{
result.Add(idString);
}
}
}
}
}
}
return Task.FromResult(result);
}
private Query BuildQuery(string query, SearchFilter? filter, SearchContext context)
{
if (queryParser == null || currentLanguages == null || !currentLanguages.SetEquals(context.Languages))
{
var fields = context.Languages.Union(Invariant).ToArray();
queryParser = new MultiFieldQueryParser(Version, fields, index.Analyzer);
currentLanguages = context.Languages;
}
try
{
var byQuery = queryParser.Parse(query);
if (filter?.SchemaIds.Count > 0)
{
var bySchemas = new BooleanQuery
{
Boost = 2f
};
foreach (var schemaId in filter.SchemaIds)
{
var term = new Term(MetaSchemaId, schemaId.ToString());
bySchemas.Add(new TermQuery(term), Occur.SHOULD);
}
var occur = filter.Must ? Occur.MUST : Occur.SHOULD;
return new BooleanQuery
{
{ byQuery, Occur.MUST },
{ bySchemas, occur }
};
}
return byQuery;
}
catch (ParseException ex)
{
throw new ValidationException(ex.Message);
}
}
private async Task<bool> TryCommitAsync()
{
timer?.Dispose();
updates++;
if (updates >= MaxUpdates)
{
await CommitAsync();
return true;
}
else
{
index.MarkStale();
try
{
timer = RegisterTimer(_ => CommitAsync(), null, CommitDelay, CommitDelay);
}
catch (InvalidOperationException)
{
return false;
}
}
return false;
}
public async Task CommitAsync()
{
if (updates > 0)
{
await indexManager.CommitAsync(index);
updates = 0;
}
}
public Task IndexAsync(NamedId<DomainId> schemaId, Immutable<IndexCommand[]> updates)
{
foreach (var command in updates.Value)
{
switch (command)
{
case DeleteIndexEntry delete:
index.Writer.DeleteDocuments(new Term(MetaId, delete.DocId));
break;
case UpdateIndexEntry update:
try
{
var values = GetValue(update.ServeAll, update.ServePublished);
index.Writer.UpdateBinaryDocValue(new Term(MetaId, update.DocId), MetaFor, values);
}
catch (ArgumentException)
{
}
break;
case UpsertIndexEntry upsert:
{
var document = new Document();
document.AddStringField(MetaId, upsert.DocId, Field.Store.YES);
document.AddStringField(MetaContentId, upsert.ContentId.ToString(), Field.Store.YES);
document.AddStringField(MetaSchemaId, schemaId.Id.ToString(), Field.Store.YES);
document.AddStringField(MetaSchemaName, schemaId.Name, Field.Store.YES);
document.AddBinaryDocValuesField(MetaFor, GetValue(upsert.ServeAll, upsert.ServePublished));
foreach (var (key, value) in upsert.Texts)
{
document.AddTextField(key, value, Field.Store.NO);
}
index.Writer.UpdateDocument(new Term(MetaId, upsert.DocId), document);
break;
}
}
}
return TryCommitAsync();
}
private static BytesRef GetValue(bool forDraft, bool forPublished)
{
return new BytesRef(new[]
{
(byte)(forDraft ? 1 : 0),
(byte)(forPublished ? 1 : 0)
});
}
}
}

65
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/MultiLanguageAnalyzer.cs

@ -1,65 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Util;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed class MultiLanguageAnalyzer : AnalyzerWrapper
{
private readonly StandardAnalyzer fallbackAnalyzer;
private readonly Dictionary<string, Analyzer> analyzers = new Dictionary<string, Analyzer>(StringComparer.OrdinalIgnoreCase);
public MultiLanguageAnalyzer(LuceneVersion version)
: base(PER_FIELD_REUSE_STRATEGY)
{
fallbackAnalyzer = new StandardAnalyzer(version);
foreach (var type in typeof(StandardAnalyzer).Assembly.GetTypes())
{
if (typeof(Analyzer).IsAssignableFrom(type))
{
var language = type.Namespace!.Split('.').Last();
if (language.Length == 2)
{
try
{
var analyzer = Activator.CreateInstance(type, version)!;
analyzers[language] = (Analyzer)analyzer;
}
catch (MissingMethodException)
{
continue;
}
}
}
}
}
protected override Analyzer GetWrappedAnalyzer(string fieldName)
{
if (fieldName.Length >= 2)
{
var analyzer = analyzers.GetOrDefault(fieldName.Substring(0, 2)) ?? fallbackAnalyzer;
return analyzer;
}
else
{
return fallbackAnalyzer;
}
}
}
}

113
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/AssetIndexStorage.cs

@ -1,113 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using LuceneDirectory = Lucene.Net.Store.Directory;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene.Storage
{
public sealed class AssetIndexStorage : IIndexStorage
{
private const string ArchiveFile = "Archive.zip";
private readonly IAssetStore assetStore;
public AssetIndexStorage(IAssetStore assetStore)
{
Guard.NotNull(assetStore, nameof(assetStore));
this.assetStore = assetStore;
}
public async Task<LuceneDirectory> CreateDirectoryAsync(DomainId ownerId)
{
var directoryInfo = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "LocalIndices", ownerId.ToString()));
if (directoryInfo.Exists)
{
directoryInfo.Delete(true);
}
directoryInfo.Create();
using (var fileStream = GetArchiveStream(directoryInfo))
{
try
{
await assetStore.DownloadAsync(directoryInfo.Name, fileStream);
fileStream.Position = 0;
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, true))
{
zipArchive.ExtractToDirectory(directoryInfo.FullName);
}
}
catch (AssetNotFoundException)
{
}
}
var directory = FSDirectory.Open(directoryInfo);
return directory;
}
public async Task WriteAsync(LuceneDirectory directory, SnapshotDeletionPolicy snapshotter)
{
var directoryInfo = ((FSDirectory)directory).Directory;
var commit = snapshotter.Snapshot();
try
{
using (var fileStream = GetArchiveStream(directoryInfo))
{
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, true))
{
foreach (var fileName in commit.FileNames)
{
var file = new FileInfo(Path.Combine(directoryInfo.FullName, fileName));
zipArchive.CreateEntryFromFile(file.FullName, file.Name);
}
}
fileStream.Position = 0;
await assetStore.UploadAsync(directoryInfo.Name, fileStream, true);
}
}
finally
{
snapshotter.Release(commit);
}
}
private static FileStream GetArchiveStream(DirectoryInfo directoryInfo)
{
var path = Path.Combine(directoryInfo.FullName, ArchiveFile);
return new FileStream(
path,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.None,
4096,
FileOptions.DeleteOnClose);
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
}
}

37
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/FileIndexStorage.cs

@ -1,37 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.Threading.Tasks;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Squidex.Infrastructure;
using LuceneDirectory = Lucene.Net.Store.Directory;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene.Storage
{
public sealed class FileIndexStorage : IIndexStorage
{
public Task<LuceneDirectory> CreateDirectoryAsync(DomainId ownerId)
{
var folderName = $"Indexes/{ownerId}";
var folderPath = Path.Combine(Path.GetTempPath(), folderName);
return Task.FromResult<LuceneDirectory>(FSDirectory.Open(folderPath));
}
public Task WriteAsync(LuceneDirectory directory, SnapshotDeletionPolicy snapshotter)
{
return Task.CompletedTask;
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
}
}

23
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/IIndexStorage.cs

@ -1,23 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public interface IIndexStorage
{
Task<Directory> CreateDirectoryAsync(DomainId ownerId);
Task WriteAsync(Directory directory, SnapshotDeletionPolicy snapshotter);
Task ClearAsync();
}
}

69
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Caching;
@ -15,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
public sealed class CachingTextIndexerState : ITextIndexerState public sealed class CachingTextIndexerState : ITextIndexerState
{ {
private readonly ITextIndexerState inner; private readonly ITextIndexerState inner;
private LRUCache<(DomainId, DomainId), Tuple<TextContentState?>> cache = new LRUCache<(DomainId, DomainId), Tuple<TextContentState?>>(1000); private readonly LRUCache<DomainId, Tuple<TextContentState?>> cache = new LRUCache<DomainId, Tuple<TextContentState?>>(10000);
public CachingTextIndexerState(ITextIndexerState inner) public CachingTextIndexerState(ITextIndexerState inner)
{ {
@ -28,37 +29,69 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{ {
await inner.ClearAsync(); await inner.ClearAsync();
cache = new LRUCache<(DomainId, DomainId), Tuple<TextContentState?>>(1000); cache.Clear();
} }
public async Task<TextContentState?> GetAsync(DomainId appId, DomainId contentId) public async Task<Dictionary<DomainId, TextContentState>> GetAsync(HashSet<DomainId> ids)
{ {
if (cache.TryGetValue((appId, contentId), out var value)) Guard.NotNull(ids, nameof(ids));
var missingIds = new HashSet<DomainId>();
var result = new Dictionary<DomainId, TextContentState>();
foreach (var id in ids)
{ {
return value.Item1; if (cache.TryGetValue(id, out var state))
{
if (state.Item1 != null)
{
result[id] = state.Item1;
}
}
else
{
missingIds.Add(id);
}
} }
var result = await inner.GetAsync(appId, contentId); if (missingIds.Count > 0)
{
var fromInner = await inner.GetAsync(missingIds);
cache.Set((appId, contentId), Tuple.Create(result)); foreach (var (id, state) in fromInner)
{
result[id] = state;
}
return result; foreach (var id in missingIds)
} {
var state = fromInner.GetOrDefault(id);
public Task SetAsync(DomainId appId, TextContentState state) cache.Set(id, Tuple.Create<TextContentState?>(state));
{ }
Guard.NotNull(state, nameof(state)); }
cache.Set((appId, state.ContentId), Tuple.Create<TextContentState?>(state));
return inner.SetAsync(appId, state); return result;
} }
public Task RemoveAsync(DomainId appId, DomainId contentId) public Task SetAsync(List<TextContentState> updates)
{ {
cache.Set((appId, contentId), Tuple.Create<TextContentState?>(null)); Guard.NotNull(updates, nameof(updates));
foreach (var update in updates)
{
if (update.IsDeleted)
{
cache.Set(update.UniqueContentId, Tuple.Create<TextContentState?>(null));
}
else
{
cache.Set(update.UniqueContentId, Tuple.Create<TextContentState?>(update));
}
}
return inner.RemoveAsync(appId, contentId); return inner.SetAsync(updates);
} }
} }
} }

7
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/ITextIndexerState.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -12,11 +13,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{ {
public interface ITextIndexerState public interface ITextIndexerState
{ {
Task<TextContentState?> GetAsync(DomainId appId, DomainId contentId); Task<Dictionary<DomainId, TextContentState>> GetAsync(HashSet<DomainId> ids);
Task SetAsync(DomainId appId, TextContentState state); Task SetAsync(List<TextContentState> updates);
Task RemoveAsync(DomainId appId, DomainId contentId);
Task ClearAsync(); Task ClearAsync();
} }

38
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/InMemoryTextIndexerState.cs

@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{ {
public sealed class InMemoryTextIndexerState : ITextIndexerState public sealed class InMemoryTextIndexerState : ITextIndexerState
{ {
private readonly Dictionary<(DomainId, DomainId), TextContentState> states = new Dictionary<(DomainId, DomainId), TextContentState>(); private readonly Dictionary<DomainId, TextContentState> states = new Dictionary<DomainId, TextContentState>();
public Task ClearAsync() public Task ClearAsync()
{ {
@ -22,26 +22,38 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<TextContentState?> GetAsync(DomainId appId, DomainId contentId) public Task<Dictionary<DomainId, TextContentState>> GetAsync(HashSet<DomainId> ids)
{ {
if (states.TryGetValue((appId, contentId), out var result)) Guard.NotNull(ids, nameof(ids));
var result = new Dictionary<DomainId, TextContentState>();
foreach (var id in ids)
{ {
return Task.FromResult<TextContentState?>(result); if (states.TryGetValue(id, out var state))
{
result.Add(id, state);
}
} }
return Task.FromResult<TextContentState?>(null); return Task.FromResult(result);
} }
public Task SetAsync(DomainId appId, TextContentState state) public Task SetAsync(List<TextContentState> updates)
{ {
states[(appId, state.ContentId)] = state; Guard.NotNull(updates, nameof(updates));
return Task.CompletedTask;
}
public Task RemoveAsync(DomainId appId, DomainId contentId) foreach (var update in updates)
{ {
states.Remove((appId, contentId)); if (update.IsDeleted)
{
states.Remove(update.UniqueContentId);
}
else
{
states[update.UniqueContentId] = update;
}
}
return Task.CompletedTask; return Task.CompletedTask;
} }

12
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/TextContentState.cs

@ -11,7 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{ {
public sealed class TextContentState public sealed class TextContentState
{ {
public DomainId ContentId { get; set; } public DomainId UniqueContentId { get; set; }
public string DocIdCurrent { get; set; } public string DocIdCurrent { get; set; }
@ -19,15 +19,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
public string? DocIdForPublished { get; set; } public string? DocIdForPublished { get; set; }
public bool IsDeleted { get; set; }
public void GenerateDocIdNew() public void GenerateDocIdNew()
{ {
if (DocIdCurrent?.EndsWith("_2") != false) if (DocIdCurrent?.EndsWith("_2") != false)
{ {
DocIdNew = $"{ContentId}_1"; DocIdNew = $"{UniqueContentId}_1";
} }
else else
{ {
DocIdNew = $"{ContentId}_2"; DocIdNew = $"{UniqueContentId}_2";
} }
} }
@ -35,11 +37,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{ {
if (DocIdNew?.EndsWith("_2") != false) if (DocIdNew?.EndsWith("_2") != false)
{ {
DocIdCurrent = $"{ContentId}_1"; DocIdCurrent = $"{UniqueContentId}_1";
} }
else else
{ {
DocIdCurrent = $"{ContentId}_2"; DocIdCurrent = $"{UniqueContentId}_2";
} }
} }
} }

440
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text.State; using Squidex.Domain.Apps.Entities.Contents.Text.State;
@ -17,280 +19,346 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
public sealed class TextIndexingProcess : IEventConsumer public sealed class TextIndexingProcess : IEventConsumer
{ {
private const string NotFound = "<404>"; private const string NotFound = "<404>";
private readonly ITextIndex textIndexer; private readonly ITextIndex textIndex;
private readonly ITextIndexerState textIndexerState; private readonly ITextIndexerState textIndexerState;
public string Name public int BatchSize
{ {
get { return "TextIndexer4"; } get { return 1000; }
} }
public string EventsFilter public int BatchDelay
{ {
get { return "^content-"; } get { return 1000; }
} }
public ITextIndex TextIndexer public string Name
{ {
get { return textIndexer; } get { return "TextIndexer5"; }
} }
public TextIndexingProcess(ITextIndex textIndexer, ITextIndexerState textIndexerState) public string EventsFilter
{ {
Guard.NotNull(textIndexer, nameof(textIndexer)); get { return "^content-"; }
Guard.NotNull(textIndexerState, nameof(textIndexerState));
this.textIndexer = textIndexer;
this.textIndexerState = textIndexerState;
} }
public bool Handles(StoredEvent @event) public ITextIndex TextIndex
{ {
return true; get { return textIndex; }
} }
public async Task ClearAsync() private sealed class Updates
{ {
await textIndexer.ClearAsync(); private readonly Dictionary<DomainId, TextContentState> states;
await textIndexerState.ClearAsync(); private readonly Dictionary<DomainId, TextContentState> updates = new Dictionary<DomainId, TextContentState>();
} private readonly Dictionary<string, IndexCommand> commands = new Dictionary<string, IndexCommand>();
public async Task On(Envelope<IEvent> @event) public Updates(Dictionary<DomainId, TextContentState> states)
{
switch (@event.Payload)
{ {
case ContentCreated created: this.states = states;
await CreateAsync(created, created.Data); }
break;
case ContentUpdated updated:
await UpdateAsync(updated, updated.Data);
break;
case ContentStatusChanged statusChanged when statusChanged.Status == Status.Published:
await PublishAsync(statusChanged);
break;
case ContentStatusChanged statusChanged:
await UnpublishAsync(statusChanged);
break;
case ContentDraftDeleted draftDelted:
await DeleteDraftAsync(draftDelted);
break;
case ContentDeleted deleted:
await DeleteAsync(deleted);
break;
case ContentDraftCreated draftCreated:
{
await CreateDraftAsync(draftCreated);
if (draftCreated.MigratedData != null) public async Task WriteAsync(ITextIndex textIndex, ITextIndexerState textIndexerState)
{
if (commands.Count > 0)
{
await textIndex.ExecuteAsync(commands.Values.ToArray());
}
if (updates.Count > 0)
{
await textIndexerState.SetAsync(updates.Values.ToList());
}
}
public void On(Envelope<IEvent> @event)
{
switch (@event.Payload)
{
case ContentCreated created:
Create(created, created.Data);
break;
case ContentUpdated updated:
Update(updated, updated.Data);
break;
case ContentStatusChanged statusChanged when statusChanged.Status == Status.Published:
Publish(statusChanged);
break;
case ContentStatusChanged statusChanged:
Unpublish(statusChanged);
break;
case ContentDraftDeleted draftDelted:
DeleteDraft(draftDelted);
break;
case ContentDeleted deleted:
Delete(deleted);
break;
case ContentDraftCreated draftCreated:
{ {
await UpdateAsync(draftCreated, draftCreated.MigratedData); CreateDraft(draftCreated);
if (draftCreated.MigratedData != null)
{
Update(draftCreated, draftCreated.MigratedData);
}
} }
}
break; break;
}
} }
}
private async Task CreateAsync(ContentEvent @event, NamedContentData data) private void Create(ContentEvent @event, NamedContentData data)
{
var appId = @event.AppId.Id;
var state = new TextContentState
{ {
ContentId = @event.ContentId var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
};
state.GenerateDocIdCurrent(); var state = new TextContentState
await IndexAsync(@event,
new UpsertIndexEntry
{ {
ContentId = @event.ContentId, UniqueContentId = uniqueId
DocId = state.DocIdCurrent, };
ServeAll = true,
ServePublished = false,
Texts = data.ToTexts()
});
await textIndexerState.SetAsync(appId, state);
}
private async Task CreateDraftAsync(ContentEvent @event) state.GenerateDocIdCurrent();
{
var appId = @event.AppId.Id;
var state = await textIndexerState.GetAsync(appId, @event.ContentId); Index(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = false,
Texts = data.ToTexts()
});
if (state != null) states[state.UniqueContentId] = state;
{
state.GenerateDocIdNew();
await textIndexerState.SetAsync(appId, state); updates[state.UniqueContentId] = state;
} }
}
private async Task UpdateAsync(ContentEvent @event, NamedContentData data) private void CreateDraft(ContentEvent @event)
{ {
var appId = @event.AppId.Id; var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
var state = await textIndexerState.GetAsync(appId, @event.ContentId); if (states.TryGetValue(uniqueId, out var state))
{
state.GenerateDocIdNew();
updates[state.UniqueContentId] = state;
}
}
if (state != null) private void Unpublish(ContentEvent @event)
{ {
if (state.DocIdNew != null) var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
if (states.TryGetValue(uniqueId, out var state) && state.DocIdForPublished != null)
{ {
await IndexAsync(@event, Index(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdNew,
ServeAll = true,
ServePublished = false,
Texts = data.ToTexts()
},
new UpdateIndexEntry new UpdateIndexEntry
{ {
DocId = state.DocIdCurrent, DocId = state.DocIdForPublished,
ServeAll = false,
ServePublished = true
});
}
else
{
var isPublished = state.DocIdCurrent == state.DocIdForPublished;
await IndexAsync(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdCurrent,
ServeAll = true, ServeAll = true,
ServePublished = isPublished, ServePublished = false
Texts = data.ToTexts()
}); });
}
await textIndexerState.SetAsync(appId, state); state.DocIdForPublished = null;
updates[state.UniqueContentId] = state;
}
} }
}
private async Task UnpublishAsync(ContentEvent @event) private void Update(ContentEvent @event, NamedContentData data)
{ {
var appId = @event.AppId.Id; var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
var state = await textIndexerState.GetAsync(appId, @event.ContentId); if (states.TryGetValue(uniqueId, out var state))
{
if (state.DocIdNew != null)
{
Index(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdNew,
ServeAll = true,
ServePublished = false,
Texts = data.ToTexts()
});
Index(@event,
new UpdateIndexEntry
{
DocId = state.DocIdCurrent,
ServeAll = false,
ServePublished = true
});
}
else
{
var isPublished = state.DocIdCurrent == state.DocIdForPublished;
Index(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = isPublished,
Texts = data.ToTexts()
});
}
}
}
if (state?.DocIdForPublished != null) private void Publish(ContentEvent @event)
{ {
await IndexAsync(@event, var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
new UpdateIndexEntry
if (states.TryGetValue(uniqueId, out var state))
{
if (state.DocIdNew != null)
{ {
DocId = state.DocIdForPublished, Index(@event,
ServeAll = true, new UpdateIndexEntry
ServePublished = false {
}); DocId = state.DocIdNew,
ServeAll = true,
ServePublished = true
});
Index(@event,
new DeleteIndexEntry
{
DocId = state.DocIdCurrent
});
state.DocIdForPublished = state.DocIdNew;
state.DocIdCurrent = state.DocIdNew;
}
else
{
Index(@event,
new UpdateIndexEntry
{
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = true
});
state.DocIdForPublished = state.DocIdCurrent;
}
state.DocIdForPublished = null; state.DocIdNew = null;
await textIndexerState.SetAsync(appId, state); updates[state.UniqueContentId] = state;
}
} }
}
private async Task PublishAsync(ContentEvent @event) private void DeleteDraft(ContentEvent @event)
{
var appId = @event.AppId.Id;
var state = await textIndexerState.GetAsync(appId, @event.ContentId);
if (state != null)
{ {
if (state.DocIdNew != null) var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
if (states.TryGetValue(uniqueId, out var state) && state.DocIdNew != null)
{ {
await IndexAsync(@event, Index(@event,
new UpdateIndexEntry new UpdateIndexEntry
{ {
DocId = state.DocIdNew, DocId = state.DocIdCurrent,
ServeAll = true, ServeAll = true,
ServePublished = true ServePublished = true
}, });
Index(@event,
new DeleteIndexEntry new DeleteIndexEntry
{ {
DocId = state.DocIdCurrent DocId = state.DocIdNew
}); });
state.DocIdForPublished = state.DocIdNew; state.DocIdNew = null;
state.DocIdCurrent = state.DocIdNew;
updates[state.UniqueContentId] = state;
} }
else }
private void Delete(ContentEvent @event)
{
var uniqueId = DomainId.Combine(@event.AppId, @event.ContentId);
if (states.TryGetValue(uniqueId, out var state))
{ {
await IndexAsync(@event, Index(@event,
new UpdateIndexEntry new DeleteIndexEntry
{ {
DocId = state.DocIdCurrent, DocId = state.DocIdCurrent
ServeAll = true, });
ServePublished = true
Index(@event,
new DeleteIndexEntry
{
DocId = state.DocIdNew ?? NotFound
}); });
state.DocIdForPublished = state.DocIdCurrent; state.IsDeleted = true;
updates[state.UniqueContentId] = state;
} }
}
state.DocIdNew = null; private void Index(ContentEvent @event, IndexCommand command)
{
command.AppId = @event.AppId;
command.SchemaId = @event.SchemaId;
await textIndexerState.SetAsync(appId, state); if (command is UpdateIndexEntry update &&
commands.TryGetValue(command.DocId, out var existing) &&
existing is UpsertIndexEntry upsert)
{
upsert.ServeAll = update.ServeAll;
upsert.ServePublished = update.ServePublished;
}
else
{
commands[command.DocId] = command;
}
} }
} }
private async Task DeleteDraftAsync(ContentEvent @event) public TextIndexingProcess(ITextIndex textIndexer, ITextIndexerState textIndexerState)
{ {
var appId = @event.AppId.Id; Guard.NotNull(textIndexer, nameof(textIndexer));
Guard.NotNull(textIndexerState, nameof(textIndexerState));
var state = await textIndexerState.GetAsync(appId, @event.ContentId);
if (state?.DocIdNew != null)
{
await IndexAsync(@event,
new UpdateIndexEntry
{
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = true
},
new DeleteIndexEntry
{
DocId = state.DocIdNew
});
state.DocIdNew = null; this.textIndex = textIndexer;
this.textIndexerState = textIndexerState;
}
await textIndexerState.SetAsync(appId, state); public async Task ClearAsync()
} {
await textIndex.ClearAsync();
await textIndexerState.ClearAsync();
} }
private async Task DeleteAsync(ContentEvent @event) public async Task On(IEnumerable<Envelope<IEvent>> @events)
{ {
var appId = @event.AppId.Id; var states = await QueryStatesAsync(events);
var state = await textIndexerState.GetAsync(appId, @event.ContentId); var updates = new Updates(states);
if (state != null) foreach (var @event in events)
{ {
await IndexAsync(@event, updates.On(@event);
new DeleteIndexEntry
{
DocId = state.DocIdCurrent
},
new DeleteIndexEntry
{
DocId = state.DocIdNew ?? NotFound
});
await textIndexerState.RemoveAsync(appId, state.ContentId);
} }
await updates.WriteAsync(textIndex, textIndexerState);
} }
private Task IndexAsync(ContentEvent @event, params IndexCommand[] commands) private Task<Dictionary<DomainId, TextContentState>> QueryStatesAsync(IEnumerable<Envelope<IEvent>> events)
{ {
return textIndexer.ExecuteAsync(@event.AppId, @event.SchemaId, commands); var ids =
events
.Select(x => x.Payload).OfType<ContentEvent>()
.Select(x => x.ContentId)
.ToHashSet();
return textIndexerState.GetAsync(ids);
} }
} }
} }

57
backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs

@ -22,14 +22,19 @@ namespace Squidex.Domain.Apps.Entities.History
private readonly IHistoryEventRepository repository; private readonly IHistoryEventRepository repository;
private readonly NotifoService notifo; private readonly NotifoService notifo;
public string Name public int BatchSize
{ {
get { return GetType().Name; } get { return 1000; }
} }
public string EventsFilter public int BatchDelay
{ {
get { return ".*"; } get { return 1000; }
}
public string Name
{
get { return GetType().Name; }
} }
public HistoryService(IHistoryEventRepository repository, IEnumerable<IHistoryEventsCreator> creators, NotifoService notifo) public HistoryService(IHistoryEventRepository repository, IEnumerable<IHistoryEventsCreator> creators, NotifoService notifo)
@ -53,38 +58,48 @@ namespace Squidex.Domain.Apps.Entities.History
this.notifo = notifo; this.notifo = notifo;
} }
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync() public Task ClearAsync()
{ {
return repository.ClearAsync(); return repository.ClearAsync();
} }
public async Task On(Envelope<IEvent> @event) public async Task On(IEnumerable<Envelope<IEvent>> @events)
{ {
await notifo.HandleEventAsync(@event); var targets = new List<(Envelope<AppEvent> Event, HistoryEvent? HistoryEvent)>();
foreach (var creator in creators) foreach (var @event in events)
{ {
var historyEvent = await creator.CreateEventAsync(@event); if (@event.Payload is AppEvent)
if (historyEvent != null)
{ {
var appEvent = @event.To<AppEvent>(); var appEvent = @event.To<AppEvent>();
await notifo.HandleHistoryEventAsync(appEvent, historyEvent); HistoryEvent? historyEvent = null;
foreach (var creator in creators)
{
historyEvent = await creator.CreateEventAsync(@event);
if (historyEvent != null)
{
historyEvent.Actor = appEvent.Payload.Actor;
historyEvent.AppId = appEvent.Payload.AppId.Id;
historyEvent.Created = @event.Headers.Timestamp();
historyEvent.Version = @event.Headers.EventStreamNumber();
historyEvent.Actor = appEvent.Payload.Actor; break;
historyEvent.AppId = appEvent.Payload.AppId.Id; }
historyEvent.Created = @event.Headers.Timestamp(); }
historyEvent.Version = @event.Headers.EventStreamNumber();
await repository.InsertAsync(historyEvent); targets.Add((appEvent, historyEvent));
} }
} }
if (targets.Count > 0)
{
await notifo.HandleEventsAsync(targets);
await repository.InsertManyAsync(targets.NotNull(x => x.HistoryEvent));
}
} }
public async Task<IReadOnlyList<ParsedHistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count) public async Task<IReadOnlyList<ParsedHistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count)

176
backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Grpc.Core; using Grpc.Core;
@ -117,32 +119,33 @@ namespace Squidex.Domain.Apps.Entities.History
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.NotifoKey, response.User.ApiKey); await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.NotifoKey, response.User.ApiKey);
} }
public async Task HandleEventAsync(Envelope<IEvent> @event) public async Task HandleEventsAsync(IEnumerable<(Envelope<AppEvent> AppEvent, HistoryEvent? HistoryEvent)> events)
{ {
Guard.NotNull(@event, nameof(@event)); Guard.NotNull(events, nameof(events));
if (client == null) if (client == null)
{ {
return; return;
} }
switch (@event.Payload) var now = clock.GetCurrentInstant();
var publishedEvents = events
.Where(x => IsTooOld(x.AppEvent.Headers, now) == false)
.Where(x => IsComment(x.AppEvent.Payload) || x.HistoryEvent != null)
.ToList();
if (publishedEvents.Any())
{ {
case CommentCreated comment: using (var stream = client.PublishMany())
{
foreach (var @event in publishedEvents)
{ {
if (IsTooOld(@event.Headers)) var payload = @event.AppEvent.Payload;
{
return;
}
if (comment.Mentions == null || comment.Mentions.Length == 0) if (payload is CommentCreated comment && IsComment(payload))
{ {
break; foreach (var userId in comment.Mentions!)
}
using (var stream = client.PublishMany())
{
foreach (var userId in comment.Mentions)
{ {
var publishRequest = new PublishRequest var publishRequest = new PublishRequest
{ {
@ -164,52 +167,87 @@ namespace Squidex.Domain.Apps.Entities.History
await stream.RequestStream.WriteAsync(publishRequest); await stream.RequestStream.WriteAsync(publishRequest);
} }
await stream.RequestStream.CompleteAsync();
await stream.ResponseAsync;
} }
else if (@event.HistoryEvent != null)
{
var historyEvent = @event.HistoryEvent;
break; var publishRequest = new PublishRequest
} {
AppId = options.AppId
};
case AppContributorAssigned contributorAssigned: foreach (var (key, value) in historyEvent.Parameters)
{ {
var user = await userResolver.FindByIdAsync(contributorAssigned.ContributorId); publishRequest.Properties.Add(key, value);
}
if (user != null) publishRequest.Properties["SquidexApp"] = payload.AppId.Name;
{
await UpsertUserAsync(user);
}
var request = BuildAllowedTopicRequest(contributorAssigned, contributorAssigned.ContributorId); if (payload is ContentEvent c && !(payload is ContentDeleted))
{
var url = urlGenerator.ContentUI(c.AppId, c.SchemaId, c.ContentId);
try publishRequest.Properties["SquidexUrl"] = url;
{ }
await client.AddAllowedTopicAsync(request);
} publishRequest.TemplateCode = @event.HistoryEvent.EventType;
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
break;
}
break; SetUser(payload, publishRequest);
SetTopic(payload, publishRequest, historyEvent);
await stream.RequestStream.WriteAsync(publishRequest);
}
} }
case AppContributorRemoved contributorRemoved: await stream.RequestStream.CompleteAsync();
{ await stream.ResponseAsync;
var request = BuildAllowedTopicRequest(contributorRemoved, contributorRemoved.ContributorId); }
}
try foreach (var @event in events)
{
switch (@event.AppEvent.Payload)
{
case AppContributorAssigned contributorAssigned:
{ {
await client.RemoveAllowedTopicAsync(request); var user = await userResolver.FindByIdAsync(contributorAssigned.ContributorId);
if (user != null)
{
await UpsertUserAsync(user);
}
var request = BuildAllowedTopicRequest(contributorAssigned, contributorAssigned.ContributorId);
try
{
await client.AddAllowedTopicAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
break;
}
break;
} }
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
case AppContributorRemoved contributorRemoved:
{ {
var request = BuildAllowedTopicRequest(contributorRemoved, contributorRemoved.ContributorId);
try
{
await client.RemoveAllowedTopicAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
break;
}
break; break;
} }
}
break;
}
} }
} }
@ -226,52 +264,14 @@ namespace Squidex.Domain.Apps.Entities.History
return topicRequest; return topicRequest;
} }
public async Task HandleHistoryEventAsync(Envelope<AppEvent> @event, HistoryEvent historyEvent) private static bool IsTooOld(EnvelopeHeaders headers, Instant now)
{ {
if (client == null) return now - headers.Timestamp() > MaxAge;
{
return;
}
if (IsTooOld(@event.Headers))
{
return;
}
var appEvent = @event.Payload;
var publishRequest = new PublishRequest
{
AppId = options.AppId
};
foreach (var (key, value) in historyEvent.Parameters)
{
publishRequest.Properties.Add(key, value);
}
publishRequest.Properties["SquidexApp"] = appEvent.AppId.Name;
if (appEvent is ContentEvent c && !(appEvent is ContentDeleted))
{
var url = urlGenerator.ContentUI(c.AppId, c.SchemaId, c.ContentId);
publishRequest.Properties["SquidexUrl"] = url;
}
publishRequest.TemplateCode = historyEvent.EventType;
SetUser(appEvent, publishRequest);
SetTopic(appEvent, publishRequest, historyEvent);
await client.PublishAsync(publishRequest);
} }
private bool IsTooOld(EnvelopeHeaders headers) private static bool IsComment(AppEvent appEvent)
{ {
var now = clock.GetCurrentInstant(); return appEvent is CommentCreated comment && comment.Mentions?.Length > 0;
return now - headers.Timestamp() > MaxAge;
} }
private static void SetUser(AppEvent appEvent, PublishRequest publishRequest) private static void SetUser(AppEvent appEvent, PublishRequest publishRequest)

2
backend/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.History.Repositories
{ {
Task<IReadOnlyList<HistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count); Task<IReadOnlyList<HistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count);
Task InsertAsync(HistoryEvent item); Task InsertManyAsync(IEnumerable<HistoryEvent> historyEvents);
Task ClearAsync(); Task ClearAsync();
} }

15
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -33,11 +33,6 @@ namespace Squidex.Domain.Apps.Entities.Rules
get { return GetType().Name; } get { return GetType().Name; }
} }
public string EventsFilter
{
get { return ".*"; }
}
public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, IRuleEventRepository ruleEventRepository, public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, IRuleEventRepository ruleEventRepository,
RuleService ruleService) RuleService ruleService)
{ {
@ -55,16 +50,6 @@ namespace Squidex.Domain.Apps.Entities.Rules
this.localCache = localCache; this.localCache = localCache;
} }
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
public async Task Enqueue(Rule rule, DomainId ruleId, Envelope<IEvent> @event) public async Task Enqueue(Rule rule, DomainId ruleId, Envelope<IEvent> @event)
{ {
Guard.NotNull(rule, nameof(rule)); Guard.NotNull(rule, nameof(rule));

4
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -25,10 +25,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="GraphQL" Version="3.0.0" /> <PackageReference Include="GraphQL" Version="3.0.0" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.Queries" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00005" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.7" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.2.2"> <PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.2.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

17
backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbSubscription.cs

@ -24,10 +24,9 @@ namespace Squidex.Infrastructure.EventSourcing
internal sealed class CosmosDbSubscription : IEventSubscription, IChangeFeedObserverFactory, IChangeFeedObserver internal sealed class CosmosDbSubscription : IEventSubscription, IChangeFeedObserverFactory, IChangeFeedObserver
{ {
private readonly TaskCompletionSource<bool> processorStopRequested = new TaskCompletionSource<bool>(); private readonly TaskCompletionSource<bool> processorStopRequested = new TaskCompletionSource<bool>();
private readonly Task processorTask;
private readonly CosmosDbEventStore store; private readonly CosmosDbEventStore store;
private readonly Regex regex; private readonly Regex regex;
private readonly string? hostName; private readonly string hostName;
private readonly IEventSubscriber subscriber; private readonly IEventSubscriber subscriber;
public CosmosDbSubscription(CosmosDbEventStore store, IEventSubscriber subscriber, string? streamFilter, string? position = null) public CosmosDbSubscription(CosmosDbEventStore store, IEventSubscriber subscriber, string? streamFilter, string? position = null)
@ -42,7 +41,7 @@ namespace Squidex.Infrastructure.EventSourcing
} }
else else
{ {
hostName = position; hostName = position ?? "none";
} }
if (!StreamFilter.IsAll(streamFilter)) if (!StreamFilter.IsAll(streamFilter))
@ -52,7 +51,7 @@ namespace Squidex.Infrastructure.EventSourcing
this.subscriber = subscriber; this.subscriber = subscriber;
processorTask = Task.Run(async () => Task.Run(async () =>
{ {
try try
{ {
@ -128,7 +127,7 @@ namespace Squidex.Infrastructure.EventSourcing
var eventData = @event.ToEventData(); var eventData = @event.ToEventData();
await subscriber.OnEventAsync(this, new StoredEvent(commit.EventStream, hostName ?? "None", eventStreamOffset, eventData)); await subscriber.OnEventAsync(this, new StoredEvent(commit.EventStream, hostName, eventStreamOffset, eventData));
} }
} }
} }
@ -136,15 +135,9 @@ namespace Squidex.Infrastructure.EventSourcing
} }
} }
public void WakeUp() public void Unsubscribe()
{
}
public Task StopAsync()
{ {
processorStopRequested.SetResult(true); processorStopRequested.SetResult(true);
return processorTask;
} }
} }
} }

9
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks;
using EventStore.ClientAPI; using EventStore.ClientAPI;
using EventStore.ClientAPI.Exceptions; using EventStore.ClientAPI.Exceptions;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
@ -44,15 +43,9 @@ namespace Squidex.Infrastructure.EventSourcing
subscription = SubscribeToStream(streamName); subscription = SubscribeToStream(streamName);
} }
public Task StopAsync() public void Unsubscribe()
{ {
subscription.Stop(); subscription.Stop();
return Task.CompletedTask;
}
public void WakeUp()
{
} }
private EventStoreCatchUpSubscription SubscribeToStream(string streamName) private EventStoreCatchUpSubscription SubscribeToStream(string streamName)

12
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs

@ -11,6 +11,7 @@ using System.Threading.Tasks;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using NodaTime; using NodaTime;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
@ -19,14 +20,13 @@ namespace Squidex.Infrastructure.EventSourcing
private readonly MongoEventStore eventStore; private readonly MongoEventStore eventStore;
private readonly IEventSubscriber eventSubscriber; private readonly IEventSubscriber eventSubscriber;
private readonly CancellationTokenSource stopToken = new CancellationTokenSource(); private readonly CancellationTokenSource stopToken = new CancellationTokenSource();
private readonly Task task;
public MongoEventStoreSubscription(MongoEventStore eventStore, IEventSubscriber eventSubscriber, string? streamFilter, string? position) public MongoEventStoreSubscription(MongoEventStore eventStore, IEventSubscriber eventSubscriber, string? streamFilter, string? position)
{ {
this.eventStore = eventStore; this.eventStore = eventStore;
this.eventSubscriber = eventSubscriber; this.eventSubscriber = eventSubscriber;
task = QueryAsync(streamFilter, position); QueryAsync(streamFilter, position).Forget();
} }
private async Task QueryAsync(string? streamFilter, string? position) private async Task QueryAsync(string? streamFilter, string? position)
@ -155,15 +155,9 @@ namespace Squidex.Infrastructure.EventSourcing
return result; return result;
} }
public Task StopAsync() public void Unsubscribe()
{ {
stopToken.Cancel(); stopToken.Cancel();
return task;
}
public void WakeUp()
{
} }
} }
} }

2
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs

@ -121,7 +121,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
using (Profiler.TraceMethod<MongoEventStore>()) using (Profiler.TraceMethod<MongoEventStore>())
{ {
await Collection.Find(filter, options: Batching.Options).Sort(Sort.Ascending(TimestampField)).ForEachPipelineAsync(async commit => await Collection.Find(filter, options: Batching.Options).Sort(Sort.Ascending(TimestampField)).ForEachPipedAsync(async commit =>
{ {
foreach (var @event in commit.Filtered(position)) foreach (var @event in commit.Filtered(position))
{ {

6
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs

@ -168,15 +168,15 @@ namespace Squidex.Infrastructure.MongoDb
return BsonClassMap.LookupClassMap(typeof(TEntity)).GetMemberMap(nameof(IVersionedEntity<TKey>.Version)).ElementName; return BsonClassMap.LookupClassMap(typeof(TEntity)).GetMemberMap(nameof(IVersionedEntity<TKey>.Version)).ElementName;
} }
public static async Task ForEachPipelineAsync<TDocument>(this IAsyncCursorSource<TDocument> source, Func<TDocument, Task> processor, CancellationToken cancellationToken = default) public static async Task ForEachPipedAsync<TDocument>(this IAsyncCursorSource<TDocument> source, Func<TDocument, Task> processor, CancellationToken cancellationToken = default)
{ {
using (var cursor = await source.ToCursorAsync(cancellationToken)) using (var cursor = await source.ToCursorAsync(cancellationToken))
{ {
await cursor.ForEachPipelineAsync(processor, cancellationToken); await cursor.ForEachPipedAsync(processor, cancellationToken);
} }
} }
public static async Task ForEachPipelineAsync<TDocument>(this IAsyncCursor<TDocument> source, Func<TDocument, Task> processor, CancellationToken cancellationToken = default) public static async Task ForEachPipedAsync<TDocument>(this IAsyncCursor<TDocument> source, Func<TDocument, Task> processor, CancellationToken cancellationToken = default)
{ {
using (var selfToken = new CancellationTokenSource()) using (var selfToken = new CancellationTokenSource())
{ {

2
backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs

@ -71,7 +71,7 @@ namespace Squidex.Infrastructure.States
{ {
using (Profiler.TraceMethod<MongoSnapshotStore<T, TKey>>()) using (Profiler.TraceMethod<MongoSnapshotStore<T, TKey>>())
{ {
await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipelineAsync(x => callback(x.Doc, x.Version), ct); await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipedAsync(x => callback(x.Doc, x.Version), ct);
} }
} }

10
backend/src/Squidex.Infrastructure.RabbitMq/CQRS/Events/RabbitMqEventConsumer.cs

@ -80,16 +80,6 @@ namespace Squidex.Infrastructure.CQRS.Events
} }
} }
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
public Task On(Envelope<IEvent> @event) public Task On(Envelope<IEvent> @event)
{ {
var jsonString = jsonSerializer.Serialize(@event); var jsonString = jsonSerializer.Serialize(@event);

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

@ -85,6 +85,11 @@ namespace Squidex.Infrastructure
return source.Where(x => x != null)!; return source.Where(x => x != null)!;
} }
public static IEnumerable<TOut> NotNull<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, TOut?> selector) where TOut : class
{
return source.Select(selector).Where(x => x != null)!;
}
public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, T value) public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, T value)
{ {
return source.Concat(Enumerable.Repeat(value, 1)); return source.Concat(Enumerable.Repeat(value, 1));

77
backend/src/Squidex.Infrastructure/EventSourcing/CompoundEventConsumer.cs

@ -1,77 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.EventSourcing
{
public sealed class CompoundEventConsumer : IEventConsumer
{
private readonly IEventConsumer[] inners;
public string Name { get; }
public string EventsFilter { get; }
public CompoundEventConsumer(IEventConsumer first, params IEventConsumer[] inners)
: this(first?.Name!, first!, inners)
{
}
public CompoundEventConsumer(IEventConsumer[] inners)
{
Guard.NotNull(inners, nameof(inners));
Guard.NotEmpty(inners, nameof(inners));
this.inners = inners;
Name = inners.First().Name;
var innerFilters =
this.inners.Where(x => !string.IsNullOrWhiteSpace(x.EventsFilter))
.Select(x => $"({x.EventsFilter})");
EventsFilter = string.Join("|", innerFilters);
}
public CompoundEventConsumer(string name, IEventConsumer first, params IEventConsumer[] inners)
{
Guard.NotNull(first, nameof(first));
Guard.NotNull(inners, nameof(inners));
Guard.NotNullOrEmpty(name, nameof(name));
this.inners = new[] { first }.Union(inners).ToArray();
Name = name;
var innerFilters =
this.inners.Where(x => !string.IsNullOrWhiteSpace(x.EventsFilter))
.Select(x => $"({x.EventsFilter})");
EventsFilter = string.Join("|", innerFilters);
}
public bool Handles(StoredEvent @event)
{
return inners.Any(x => x.Handles(@event));
}
public Task ClearAsync()
{
return Task.WhenAll(inners.Select(i => i.ClearAsync()));
}
public async Task On(Envelope<IEvent> @event)
{
foreach (var inner in inners)
{
await inner.On(@event);
}
}
}
}

193
backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs

@ -0,0 +1,193 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing.Grains
{
internal sealed class BatchSubscriber : IEventSubscriber
{
private readonly ITargetBlock<Job> pipelineStart;
private readonly IEventDataFormatter eventDataFormatter;
private readonly IEventSubscription eventSubscription;
private readonly IDataflowBlock pipelineEnd;
private sealed class Job
{
public StoredEvent? StoredEvent { get; set; }
public Exception? Exception { get; set; }
public Envelope<IEvent>? Event { get; set; }
public bool ShouldHandle { get; set; }
public object Sender { get; set; }
}
public BatchSubscriber(
EventConsumerGrain grain,
IEventDataFormatter eventDataFormatter,
IEventConsumer eventConsumer,
Func<IEventSubscriber, IEventSubscription> factory,
TaskScheduler scheduler)
{
this.eventDataFormatter = eventDataFormatter;
var batchSize = Math.Max(1, eventConsumer!.BatchSize);
var batchDelay = Math.Max(100, eventConsumer.BatchDelay);
var parse = new TransformBlock<Job, Job>(job =>
{
if (job.StoredEvent != null)
{
job.ShouldHandle = eventConsumer.Handles(job.StoredEvent);
}
if (job.ShouldHandle)
{
try
{
job.Event = ParseKnownEvent(job.StoredEvent!);
}
catch (Exception ex)
{
job.Exception = ex;
}
}
return job;
}, new ExecutionDataflowBlockOptions
{
BoundedCapacity = batchSize,
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = 1
});
var buffer = AsyncHelper.CreateBatchBlock<Job>(batchSize, batchDelay, new GroupingDataflowBlockOptions
{
BoundedCapacity = batchSize * 2
});
var handle = new ActionBlock<IList<Job>>(async jobs =>
{
foreach (var jobsBySender in jobs.GroupBy<Job, object>(x => x.Sender))
{
var sender = jobsBySender.Key;
if (ReferenceEquals(sender, eventSubscription.Sender))
{
var exception = jobs.FirstOrDefault(x => x.Exception != null)?.Exception;
if (exception != null)
{
await grain.OnErrorAsync(exception);
}
else
{
await grain.OnEventsAsync(GetEvents(jobsBySender), GetPosition(jobsBySender));
}
}
}
},
new ExecutionDataflowBlockOptions
{
BoundedCapacity = 2,
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = 1,
TaskScheduler = scheduler
});
parse.LinkTo(buffer, new DataflowLinkOptions
{
PropagateCompletion = true
});
buffer.LinkTo(handle, new DataflowLinkOptions
{
PropagateCompletion = true
});
pipelineStart = parse;
pipelineEnd = handle;
eventSubscription = factory(this);
}
private static List<Envelope<IEvent>> GetEvents(IEnumerable<Job> jobsBySender)
{
return jobsBySender.NotNull(x => x.Event).ToList();
}
private static string GetPosition(IEnumerable<Job> jobsBySender)
{
return jobsBySender.Last().StoredEvent!.EventPosition;
}
public Task CompleteAsync()
{
pipelineStart.Complete();
return pipelineEnd.Completion;
}
public void WakeUp()
{
eventSubscription.WakeUp();
}
public void Unsubscribe()
{
eventSubscription.Unsubscribe();
}
private Envelope<IEvent>? ParseKnownEvent(StoredEvent storedEvent)
{
try
{
var @event = eventDataFormatter.Parse(storedEvent.Data);
@event.SetEventPosition(storedEvent.EventPosition);
@event.SetEventStreamNumber(storedEvent.EventStreamNumber);
return @event;
}
catch (TypeNameNotFoundException)
{
return null;
}
}
public Task OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent)
{
var job = new Job
{
Sender = subscription,
StoredEvent = storedEvent
};
return pipelineStart.SendAsync(job);
}
public Task OnErrorAsync(IEventSubscription subscription, Exception exception)
{
var job = new Job
{
Sender = subscription,
Exception = exception
};
return pipelineStart.SendAsync(job);
}
}
}

153
backend/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs

@ -6,15 +6,13 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency; using Orleans.Concurrency;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing.Grains namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
@ -26,7 +24,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
private readonly IEventStore eventStore; private readonly IEventStore eventStore;
private readonly ISemanticLog log; private readonly ISemanticLog log;
private TaskScheduler? scheduler; private TaskScheduler? scheduler;
private IEventSubscription? currentSubscription; private BatchSubscriber? currentSubscriber;
private IEventConsumer? eventConsumer; private IEventConsumer? eventConsumer;
private EventConsumerState State private EventConsumerState State
@ -65,6 +63,14 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task CompleteAsync()
{
if (currentSubscriber != null)
{
await currentSubscriber.CompleteAsync();
}
}
public Task<Immutable<EventConsumerInfo>> GetStateAsync() public Task<Immutable<EventConsumerInfo>> GetStateAsync()
{ {
return Task.FromResult(CreateInfo()); return Task.FromResult(CreateInfo());
@ -75,41 +81,23 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return State.ToInfo(eventConsumer!.Name).AsImmutable(); return State.ToInfo(eventConsumer!.Name).AsImmutable();
} }
public Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent) public Task OnEventsAsync(IReadOnlyList<Envelope<IEvent>> events, string position)
{ {
if (subscription.Value != currentSubscription)
{
return Task.CompletedTask;
}
return DoAndUpdateStateAsync(async () => return DoAndUpdateStateAsync(async () =>
{ {
if (eventConsumer!.Handles(storedEvent.Value)) await DispatchAsync(events);
{
var @event = ParseKnownEvent(storedEvent.Value);
if (@event != null)
{
await DispatchConsumerAsync(@event);
}
}
State = State.Handled(storedEvent.Value.EventPosition); State = State.Handled(position, events.Count);
}); });
} }
public Task OnErrorAsync(Immutable<IEventSubscription> subscription, Immutable<Exception> exception) public Task OnErrorAsync(Exception exception)
{ {
if (subscription.Value != currentSubscription)
{
return Task.CompletedTask;
}
return DoAndUpdateStateAsync(() => return DoAndUpdateStateAsync(() =>
{ {
Unsubscribe(); Unsubscribe();
State = State.Stopped(exception.Value); State = State.Stopped(exception);
}); });
} }
@ -119,14 +107,14 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
await DoAndUpdateStateAsync(() => await DoAndUpdateStateAsync(() =>
{ {
Subscribe(State.Position); Subscribe();
State = State.Started(); State = State.Started();
}); });
} }
else if (!State.IsStopped) else if (!State.IsStopped)
{ {
Subscribe(State.Position); Subscribe();
} }
} }
@ -139,7 +127,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await DoAndUpdateStateAsync(() => await DoAndUpdateStateAsync(() =>
{ {
Subscribe(State.Position); Subscribe();
State = State.Started(); State = State.Started();
}); });
@ -172,21 +160,36 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await ClearAsync(); await ClearAsync();
Subscribe(null);
State = State.Reset(); State = State.Reset();
Subscribe();
}); });
return CreateInfo(); return CreateInfo();
} }
private async Task DispatchAsync(IReadOnlyList<Envelope<IEvent>> events)
{
if (events.Count > 0)
{
await eventConsumer!.On(events);
}
}
private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string? caller = null) private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string? caller = null)
{ {
return DoAndUpdateStateAsync(() => { action(); return Task.CompletedTask; }, caller); return DoAndUpdateStateAsync(() =>
{
action();
return Task.CompletedTask;
}, caller);
} }
private async Task DoAndUpdateStateAsync(Func<Task> action, [CallerMemberName] string? caller = null) private async Task DoAndUpdateStateAsync(Func<Task> action, [CallerMemberName] string? caller = null)
{ {
var previousState = State;
try try
{ {
await action(); await action();
@ -207,10 +210,13 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
.WriteProperty("status", "Failed") .WriteProperty("status", "Failed")
.WriteProperty("eventConsumer", eventConsumer!.Name)); .WriteProperty("eventConsumer", eventConsumer!.Name));
State = State.Stopped(ex); State = previousState.Stopped(ex);
} }
await state.WriteAsync(); if (State != previousState)
{
await state.WriteAsync();
}
} }
private async Task ClearAsync() private async Task ClearAsync()
@ -233,92 +239,43 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
} }
} }
private async Task DispatchConsumerAsync(Envelope<IEvent> @event)
{
var eventId = @event.Headers.EventId().ToString();
var eventType = @event.Payload.GetType().Name;
var logContext = (eventId, eventType, consumer: eventConsumer!.Name);
log.LogDebug(logContext, (ctx, w) => w
.WriteProperty("action", "HandleEvent")
.WriteProperty("actionId", ctx.eventId)
.WriteProperty("status", "Started")
.WriteProperty("eventId", ctx.eventId)
.WriteProperty("eventType", ctx.eventType)
.WriteProperty("eventConsumer", ctx.consumer));
using (log.MeasureInformation(logContext, (ctx, w) => w
.WriteProperty("action", "HandleEvent")
.WriteProperty("actionId", ctx.eventId)
.WriteProperty("status", "Completed")
.WriteProperty("eventId", ctx.eventId)
.WriteProperty("eventType", ctx.eventType)
.WriteProperty("eventConsumer", ctx.consumer)))
{
await eventConsumer.On(@event);
}
}
private void Unsubscribe() private void Unsubscribe()
{ {
var subscription = Interlocked.Exchange(ref currentSubscription, null); var subscription = Interlocked.Exchange(ref currentSubscriber, null);
if (subscription != null) subscription?.Unsubscribe();
{
subscription.StopAsync().Forget();
}
} }
private void Subscribe(string? position) private void Subscribe()
{ {
if (currentSubscription == null) if (currentSubscriber == null)
{ {
currentSubscription = CreateSubscription(eventConsumer!.EventsFilter, position); currentSubscriber = CreateSubscription();
} }
else else
{ {
currentSubscription.WakeUp(); currentSubscriber.WakeUp();
} }
} }
private Envelope<IEvent>? ParseKnownEvent(StoredEvent storedEvent) protected virtual TaskScheduler GetScheduler()
{
try
{
var @event = eventDataFormatter.Parse(storedEvent.Data);
@event.SetEventPosition(storedEvent.EventPosition);
@event.SetEventStreamNumber(storedEvent.EventStreamNumber);
return @event;
}
catch (TypeNameNotFoundException)
{
log.LogDebug(w => w.WriteProperty("oldEventFound", storedEvent.Data.Type));
return null;
}
}
protected virtual IEventConsumerGrain GetSelf()
{ {
return this.AsReference<IEventConsumerGrain>(); return scheduler!;
} }
protected virtual IEventSubscription CreateSubscription(IEventStore store, IEventSubscriber subscriber, string filter, string? position) private BatchSubscriber CreateSubscription()
{ {
return new RetrySubscription(store, subscriber, filter, position); return new BatchSubscriber(this, eventDataFormatter, eventConsumer!, CreateRetrySubscription, GetScheduler());
} }
protected virtual TaskScheduler GetScheduler() protected virtual IEventSubscription CreateRetrySubscription(IEventSubscriber subscriber)
{ {
return scheduler!; return new RetrySubscription(subscriber, CreateSubscription);
} }
private IEventSubscription CreateSubscription(string streamFilter, string? position) protected virtual IEventSubscription CreateSubscription(IEventSubscriber subscriber)
{ {
return CreateSubscription(eventStore, new WrapperSubscription(GetSelf(), GetScheduler()), streamFilter, position); return eventStore.CreateSubscription(subscriber, eventConsumer!.EventsFilter, State.Position);
} }
} }
} }

4
backend/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerState.cs

@ -46,9 +46,9 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return new EventConsumerState(); return new EventConsumerState();
} }
public EventConsumerState Handled(string position) public EventConsumerState Handled(string position, int offset = 1)
{ {
return new EventConsumerState(position, Count + 1); return new EventConsumerState(position, Count + offset);
} }
public EventConsumerState Stopped(Exception? ex = null) public EventConsumerState Stopped(Exception? ex = null)

5
backend/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans.Concurrency; using Orleans.Concurrency;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -21,9 +20,5 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
Task<Immutable<EventConsumerInfo>> StartAsync(); Task<Immutable<EventConsumerInfo>> StartAsync();
Task<Immutable<EventConsumerInfo>> ResetAsync(); Task<Immutable<EventConsumerInfo>> ResetAsync();
Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent);
Task OnErrorAsync(Immutable<IEventSubscription> subscription, Immutable<Exception> exception);
} }
} }

42
backend/src/Squidex.Infrastructure/EventSourcing/Grains/WrapperSubscription.cs

@ -1,42 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading;
using System.Threading.Tasks;
using Orleans.Concurrency;
namespace Squidex.Infrastructure.EventSourcing.Grains
{
internal sealed class WrapperSubscription : IEventSubscriber
{
private readonly IEventConsumerGrain grain;
private readonly TaskScheduler scheduler;
public WrapperSubscription(IEventConsumerGrain grain, TaskScheduler scheduler)
{
this.grain = grain;
this.scheduler = scheduler ?? TaskScheduler.Default;
}
public Task OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent)
{
return Dispatch(() => grain.OnEventAsync(subscription.AsImmutable(), storedEvent.AsImmutable()));
}
public Task OnErrorAsync(IEventSubscription subscription, Exception exception)
{
return Dispatch(() => grain.OnErrorAsync(subscription.AsImmutable(), exception.AsImmutable()));
}
private Task Dispatch(Func<Task> task)
{
return Task<Task>.Factory.StartNew(task, CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap();
}
}
}

30
backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
@ -13,14 +14,35 @@ namespace Squidex.Infrastructure.EventSourcing
public interface IEventConsumer public interface IEventConsumer
{ {
int BatchDelay => 500;
int BatchSize => 1;
string Name { get; } string Name { get; }
string EventsFilter { get; } string EventsFilter => ".*";
bool Handles(StoredEvent @event)
{
return true;
}
bool Handles(StoredEvent @event); Task ClearAsync()
{
return Task.CompletedTask;
}
Task ClearAsync(); Task On(Envelope<IEvent> @event)
{
return Task.CompletedTask;
}
Task On(Envelope<IEvent> @event); async Task On(IEnumerable<Envelope<IEvent>> @events)
{
foreach (var @event in events)
{
await On(@event);
}
}
} }
} }

12
backend/src/Squidex.Infrastructure/EventSourcing/IEventSubscription.cs

@ -5,14 +5,18 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
public interface IEventSubscription public interface IEventSubscription
{ {
void WakeUp(); object? Sender => this;
void Unsubscribe()
{
}
Task StopAsync(); void WakeUp()
{
}
} }
} }

6
backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs

@ -6,7 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Threading.Tasks; using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Timers; using Squidex.Infrastructure.Timers;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
@ -47,9 +47,9 @@ namespace Squidex.Infrastructure.EventSourcing
timer.SkipCurrentDelay(); timer.SkipCurrentDelay();
} }
public Task StopAsync() public void Unsubscribe()
{ {
return timer.StopAsync(); timer.StopAsync().Forget();
} }
} }
} }

97
backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs

@ -8,7 +8,6 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
#pragma warning disable RECS0002 // Convert anonymous method to method group #pragma warning disable RECS0002 // Convert anonymous method to method group
@ -16,106 +15,78 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
public sealed class RetrySubscription : IEventSubscription, IEventSubscriber public sealed class RetrySubscription : IEventSubscription, IEventSubscriber
{ {
private readonly SingleThreadedDispatcher dispatcher = new SingleThreadedDispatcher(10);
private readonly CancellationTokenSource timerCancellation = new CancellationTokenSource();
private readonly RetryWindow retryWindow = new RetryWindow(TimeSpan.FromMinutes(5), 5); private readonly RetryWindow retryWindow = new RetryWindow(TimeSpan.FromMinutes(5), 5);
private readonly IEventStore eventStore;
private readonly IEventSubscriber eventSubscriber; private readonly IEventSubscriber eventSubscriber;
private readonly string? streamFilter; private readonly Func<IEventSubscriber, IEventSubscription> eventSubscriptionFactory;
private CancellationTokenSource timerCancellation = new CancellationTokenSource();
private IEventSubscription? currentSubscription; private IEventSubscription? currentSubscription;
private string? position;
public int ReconnectWaitMs { get; set; } = 5000; public int ReconnectWaitMs { get; set; } = 5000;
public RetrySubscription(IEventStore eventStore, IEventSubscriber eventSubscriber, string? streamFilter, string? position) public object? Sender => currentSubscription?.Sender;
public RetrySubscription(IEventSubscriber eventSubscriber, Func<IEventSubscriber, IEventSubscription> eventSubscriptionFactory)
{ {
Guard.NotNull(eventStore, nameof(eventStore));
Guard.NotNull(eventSubscriber, nameof(eventSubscriber)); Guard.NotNull(eventSubscriber, nameof(eventSubscriber));
Guard.NotNull(streamFilter, nameof(streamFilter)); Guard.NotNull(eventSubscriptionFactory, nameof(eventSubscriptionFactory));
this.position = position;
this.eventStore = eventStore;
this.eventSubscriber = eventSubscriber; this.eventSubscriber = eventSubscriber;
this.eventSubscriptionFactory = eventSubscriptionFactory;
this.streamFilter = streamFilter;
Subscribe(); Subscribe();
} }
private void Subscribe() private void Subscribe()
{ {
currentSubscription ??= eventStore.CreateSubscription(this, streamFilter, position); lock (this)
}
private void Unsubscribe()
{
var subscription = Interlocked.Exchange(ref currentSubscription, null);
if (subscription != null)
{ {
subscription.StopAsync().Forget(); if (currentSubscription == null)
{
currentSubscription = eventSubscriptionFactory(this);
}
} }
} }
public void WakeUp() public void Unsubscribe()
{ {
currentSubscription?.WakeUp(); lock (this)
}
private async Task HandleEventAsync(IEventSubscription subscription, StoredEvent storedEvent)
{
if (subscription == currentSubscription)
{ {
await eventSubscriber.OnEventAsync(this, storedEvent); if (currentSubscription != null)
{
position = storedEvent.EventPosition; timerCancellation.Cancel();
} timerCancellation.Dispose();
}
private async Task HandleErrorAsync(IEventSubscription subscription, Exception exception) currentSubscription.Unsubscribe();
{ currentSubscription = null;
if (subscription == currentSubscription)
{
Unsubscribe();
if (retryWindow.CanRetryAfterFailure()) timerCancellation = new CancellationTokenSource();
{
RetryAsync().Forget();
}
else
{
await eventSubscriber.OnErrorAsync(this, exception);
} }
} }
} }
private async Task RetryAsync() public void WakeUp()
{ {
await Task.Delay(ReconnectWaitMs, timerCancellation.Token); currentSubscription?.WakeUp();
await dispatcher.DispatchAsync(Subscribe);
} }
Task IEventSubscriber.OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent) public async Task OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent)
{ {
return dispatcher.DispatchAsync(() => HandleEventAsync(subscription, storedEvent)); await eventSubscriber.OnEventAsync(subscription, storedEvent);
} }
Task IEventSubscriber.OnErrorAsync(IEventSubscription subscription, Exception exception) public async Task OnErrorAsync(IEventSubscription subscription, Exception exception)
{ {
return dispatcher.DispatchAsync(() => HandleErrorAsync(subscription, exception)); Unsubscribe();
}
public async Task StopAsync() if (retryWindow.CanRetryAfterFailure())
{ {
await dispatcher.DispatchAsync(Unsubscribe); await Task.Delay(ReconnectWaitMs, timerCancellation.Token);
await dispatcher.StopAndWaitAsync();
if (!timerCancellation.IsCancellationRequested) Subscribe();
}
else
{ {
timerCancellation.Cancel(); await eventSubscriber.OnErrorAsync(subscription, exception);
timerCancellation.Dispose();
} }
} }
} }

33
backend/src/Squidex.Infrastructure/Tasks/AsyncHelper.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
namespace Squidex.Infrastructure.Tasks namespace Squidex.Infrastructure.Tasks
{ {
@ -50,5 +51,37 @@ namespace Squidex.Infrastructure.Tasks
.GetAwaiter() .GetAwaiter()
.GetResult(); .GetResult();
} }
public static IPropagatorBlock<T, T[]> CreateBatchBlock<T>(int batchSize, int timeout, GroupingDataflowBlockOptions? dataflowBlockOptions = null)
{
dataflowBlockOptions ??= new GroupingDataflowBlockOptions();
var batchBlock = new BatchBlock<T>(batchSize, dataflowBlockOptions);
var timer = new Timer(_ => batchBlock.TriggerBatch());
var timerBlock = new TransformBlock<T, T>((T value) =>
{
timer.Change(timeout, Timeout.Infinite);
return value;
}, new ExecutionDataflowBlockOptions()
{
BoundedCapacity = 1,
CancellationToken = dataflowBlockOptions.CancellationToken,
EnsureOrdered = dataflowBlockOptions.EnsureOrdered,
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = dataflowBlockOptions.MaxMessagesPerTask,
NameFormat = dataflowBlockOptions.NameFormat,
TaskScheduler = dataflowBlockOptions.TaskScheduler
});
timerBlock.LinkTo(batchBlock, new DataflowLinkOptions()
{
PropagateCompletion = true
});
return DataflowBlock.Encapsulate(timerBlock, batchBlock);
}
} }
} }

67
backend/src/Squidex.Infrastructure/Tasks/SingleThreadedDispatcher.cs

@ -1,67 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
namespace Squidex.Infrastructure.Tasks
{
public sealed class SingleThreadedDispatcher
{
private readonly ActionBlock<Func<Task>> block;
private bool isStopped;
public SingleThreadedDispatcher(int capacity = 1)
{
var options = new ExecutionDataflowBlockOptions
{
BoundedCapacity = capacity,
MaxMessagesPerTask = 1,
MaxDegreeOfParallelism = 1
};
block = new ActionBlock<Func<Task>>(Handle, options);
}
public Task DispatchAsync(Func<Task> action)
{
Guard.NotNull(action, nameof(action));
return block.SendAsync(action);
}
public Task DispatchAsync(Action action)
{
Guard.NotNull(action, nameof(action));
return block.SendAsync(() => { action(); return Task.CompletedTask; });
}
public async Task StopAndWaitAsync()
{
await DispatchAsync(() =>
{
isStopped = true;
block.Complete();
});
await block.Completion;
}
private Task Handle(Func<Task> action)
{
if (isStopped)
{
return Task.CompletedTask;
}
return action();
}
}
}

9
backend/src/Squidex.Infrastructure/Translations/ResourcesLocalizer.cs

@ -98,11 +98,16 @@ namespace Squidex.Infrastructure.Translations
{ {
try try
{ {
variableValue = Convert.ToString(property.GetValue(args), culture); var value = property.GetValue(args);
if (value != null)
{
variableValue = Convert.ToString(value, culture) ?? variableName;
}
} }
catch catch
{ {
variableValue = null; variableValue = variableName;
} }
} }

3
backend/src/Squidex.Shared/Texts.it.resx

@ -526,6 +526,9 @@
<data name="contents.validation.duplicates" xml:space="preserve"> <data name="contents.validation.duplicates" xml:space="preserve">
<value>Non può avere valori duplicati.</value> <value>Non può avere valori duplicati.</value>
</data> </data>
<data name="contents.validation.error" xml:space="preserve">
<value>Validation failed with internal error.</value>
</data>
<data name="contents.validation.exactValue" xml:space="preserve"> <data name="contents.validation.exactValue" xml:space="preserve">
<value>Deve essere esattamente {value}.</value> <value>Deve essere esattamente {value}.</value>
</data> </data>

3
backend/src/Squidex.Shared/Texts.nl.resx

@ -526,6 +526,9 @@
<data name="contents.validation.duplicates" xml:space="preserve"> <data name="contents.validation.duplicates" xml:space="preserve">
<value>Mag geen dubbele waarden bevatten.</value> <value>Mag geen dubbele waarden bevatten.</value>
</data> </data>
<data name="contents.validation.error" xml:space="preserve">
<value>Validation failed with internal error.</value>
</data>
<data name="contents.validation.exactValue" xml:space="preserve"> <data name="contents.validation.exactValue" xml:space="preserve">
<value>Moet exact {waarde} zijn.</value> <value>Moet exact {waarde} zijn.</value>
</data> </data>

3
backend/src/Squidex.Shared/Texts.resx

@ -526,6 +526,9 @@
<data name="contents.validation.duplicates" xml:space="preserve"> <data name="contents.validation.duplicates" xml:space="preserve">
<value>Must not contain duplicate values.</value> <value>Must not contain duplicate values.</value>
</data> </data>
<data name="contents.validation.error" xml:space="preserve">
<value>Validation failed with internal error.</value>
</data>
<data name="contents.validation.exactValue" xml:space="preserve"> <data name="contents.validation.exactValue" xml:space="preserve">
<value>Must be exactly {value}.</value> <value>Must be exactly {value}.</value>
</data> </data>

21
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Queries; using Squidex.Domain.Apps.Entities.Contents.Queries;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps; using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene; using Squidex.Domain.Apps.Entities.Contents.Text.Elastic;
using Squidex.Domain.Apps.Entities.Contents.Validation; using Squidex.Domain.Apps.Entities.Contents.Validation;
using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.Search; using Squidex.Domain.Apps.Entities.Search;
@ -86,20 +86,27 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<DefaultWorkflowsValidator>() services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>(); .AsOptional<IWorkflowsValidator>();
services.AddSingletonAs<LuceneTextIndex>()
.As<ITextIndex>();
services.AddSingletonAs<TextIndexingProcess>() services.AddSingletonAs<TextIndexingProcess>()
.As<IEventConsumer>(); .As<IEventConsumer>();
services.AddSingletonAs<ContentsSearchSource>() services.AddSingletonAs<ContentsSearchSource>()
.As<ISearchSource>(); .As<ISearchSource>();
services.AddSingletonAs<IndexManager>()
.AsSelf();
services.AddSingletonAs<GrainBootstrap<IContentSchedulerGrain>>() services.AddSingletonAs<GrainBootstrap<IContentSchedulerGrain>>()
.AsSelf(); .AsSelf();
config.ConfigureByOption("fullText:type", new Alternatives
{
["Elastic"] = () =>
{
var elasticConfiguration = config.GetRequiredValue("fullText:elastic:configuration");
var elasticIndexName = config.GetRequiredValue("fullText:elastic:indexName");
services.AddSingletonAs(c => new ElasticSearchTextIndex(elasticConfiguration, elasticIndexName))
.As<ITextIndex>();
},
["Default"] = () => { }
});
} }
} }
} }

18
backend/src/Squidex/Config/Domain/StoreServices.cs

@ -14,12 +14,11 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Migrations.Migrations.MongoDb; using Migrations.Migrations.MongoDb;
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene; using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Contents.Text.State; using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Domain.Apps.Entities.History.Repositories; using Squidex.Domain.Apps.Entities.History.Repositories;
using Squidex.Domain.Apps.Entities.MongoDb.Assets; using Squidex.Domain.Apps.Entities.MongoDb.Assets;
@ -121,6 +120,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<MongoContentRepository>(c, GetDatabase(c, mongoContentDatabaseName))) services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<MongoContentRepository>(c, GetDatabase(c, mongoContentDatabaseName)))
.As<IContentRepository>().As<ISnapshotStore<ContentState, DomainId>>(); .As<IContentRepository>().As<ISnapshotStore<ContentState, DomainId>>();
services.AddSingletonAs<MongoTextIndex>()
.AsOptional<ITextIndex>();
services.AddSingletonAs<MongoTextIndexerState>() services.AddSingletonAs<MongoTextIndexerState>()
.As<ITextIndexerState>(); .As<ITextIndexerState>();
@ -131,18 +133,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<MongoPersistedGrantStore>() services.AddSingletonAs<MongoPersistedGrantStore>()
.As<IPersistedGrantStore>(); .As<IPersistedGrantStore>();
} }
services.AddSingletonAs(c =>
{
var database = c.GetRequiredService<IMongoDatabase>();
var mongoBucket = new GridFSBucket<string>(database, new GridFSBucketOptions
{
BucketName = "fullText"
});
return new MongoIndexStorage(mongoBucket);
}).As<IIndexStorage>();
} }
}); });

21
backend/src/Squidex/appsettings.json

@ -23,6 +23,27 @@
"enableXForwardedHost": false "enableXForwardedHost": false
}, },
"fullText": {
/*
* Define the type of the full text store.
*
* Supported: elastic (ElasticSearch). Default: MongoDB
*/
"type": "default",
"elastic": {
/*
* The configuration to your elastic search cluster.
*
* Read More: https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/client-configuration.html
*/
"configuration": "http://localhost:9200",
/*
* The name of the index.
*/
"indexName": "squidex"
}
},
/* /*
* Define optional paths to plugins. * Define optional paths to plugins.
*/ */

1
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentAssertions; using FluentAssertions;

1
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventEnricherTests.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;

66
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs

@ -5,13 +5,17 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions; using FluentAssertions;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -25,6 +29,68 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
private readonly List<ValidationError> errors = new List<ValidationError>(); private readonly List<ValidationError> errors = new List<ValidationError>();
private Schema schema = new Schema("my-schema"); private Schema schema = new Schema("my-schema");
[Fact]
public async Task Should_add_error_if_value_validator_throws_exception()
{
var validator = A.Fake<IValidator>();
A.CallTo(() => validator.ValidateAsync(A<object?>._, A<ValidationContext>._, A<AddError>._))
.Throws(new InvalidOperationException());
var validatorFactory = A.Fake<IValidatorsFactory>();
A.CallTo(() => validatorFactory.CreateValueValidators(A<ValidationContext>._, A<IField>._, A<FieldValidatorFactory>._))
.Returns(Enumerable.Repeat(validator, 1));
schema = schema.AddNumber(1, "my-field", Partitioning.Invariant,
new NumberFieldProperties());
var data =
new NamedContentData()
.AddField("my-field",
new ContentFieldData()
.AddValue("iv", 1000));
await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema, factory: validatorFactory);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Validation failed with internal error.", "my-field")
});
}
[Fact]
public async Task Should_add_error_if_field_validator_throws_exception()
{
var validator = A.Fake<IValidator>();
A.CallTo(() => validator.ValidateAsync(A<object?>._, A<ValidationContext>._, A<AddError>._))
.Throws(new InvalidOperationException());
var validatorFactory = A.Fake<IValidatorsFactory>();
A.CallTo(() => validatorFactory.CreateFieldValidators(A<ValidationContext>._, A<IField>._, A<FieldValidatorFactory>._))
.Returns(Enumerable.Repeat(validator, 1));
schema = schema.AddNumber(1, "my-field", Partitioning.Invariant,
new NumberFieldProperties());
var data =
new NamedContentData()
.AddField("my-field",
new ContentFieldData()
.AddValue("iv", 1000));
await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema, factory: validatorFactory);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Validation failed with internal error.", "my-field")
});
}
[Fact] [Fact]
public async Task Should_add_error_if_validating_data_with_unknown_field() public async Task Should_add_error_if_validating_data_with_unknown_field()
{ {

50
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs

@ -5,27 +5,33 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Core.ValidateContent.Validators; using Squidex.Domain.Apps.Core.ValidateContent.Validators;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Core.Operations.ValidateContent namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
{ {
public delegate ValidationContext ValidationUpdater(ValidationContext context);
public static class ValidationTestExtensions public static class ValidationTestExtensions
{ {
private static readonly NamedId<DomainId> AppId = NamedId.Of(DomainId.NewGuid(), "my-app"); private static readonly NamedId<DomainId> AppId = NamedId.Of(DomainId.NewGuid(), "my-app");
private static readonly NamedId<DomainId> SchemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); private static readonly NamedId<DomainId> SchemaId = NamedId.Of(DomainId.NewGuid(), "my-schema");
private static readonly ISemanticLog Log = A.Fake<ISemanticLog>();
private static readonly IValidatorsFactory Factory = new DefaultValidatorsFactory(); private static readonly IValidatorsFactory Factory = new DefaultValidatorsFactory();
public static Task ValidateAsync(this IValidator validator, object? value, IList<string> errors, public static Task ValidateAsync(this IValidator validator, object? value, IList<string> errors,
Schema? schema = null, ValidationMode mode = ValidationMode.Default, Func<ValidationContext, ValidationContext>? updater = null) Schema? schema = null,
ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater);
@ -33,22 +39,28 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
} }
public static Task ValidateAsync(this IField field, object? value, IList<string> errors, public static Task ValidateAsync(this IField field, object? value, IList<string> errors,
Schema? schema = null, ValidationMode mode = ValidationMode.Default, Func<ValidationContext, ValidationContext>? updater = null) Schema? schema = null,
ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null,
IValidatorsFactory? factory = null)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater);
var validators = Factory.CreateValueValidators(context, field, null!); var validators = Factories(factory).SelectMany(x => x.CreateValueValidators(context, field, null!)).ToArray();
return new FieldValidator(validators.ToArray(), field) return new FieldValidator(validators, field)
.ValidateAsync(value, context, CreateFormatter(errors)); .ValidateAsync(value, context, CreateFormatter(errors));
} }
public static async Task ValidatePartialAsync(this NamedContentData data, PartitionResolver partitionResolver, IList<ValidationError> errors, public static async Task ValidatePartialAsync(this NamedContentData data, PartitionResolver partitionResolver, IList<ValidationError> errors,
Schema? schema = null, ValidationMode mode = ValidationMode.Default, Func<ValidationContext, ValidationContext>? updater = null) Schema? schema = null,
ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null,
IValidatorsFactory? factory = null)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater);
var validator = new ContentValidator(partitionResolver, context, Enumerable.Repeat(Factory, 1)); var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log);
await validator.ValidateInputPartialAsync(data); await validator.ValidateInputPartialAsync(data);
@ -59,11 +71,14 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
} }
public static async Task ValidateAsync(this NamedContentData data, PartitionResolver partitionResolver, IList<ValidationError> errors, public static async Task ValidateAsync(this NamedContentData data, PartitionResolver partitionResolver, IList<ValidationError> errors,
Schema? schema = null, ValidationMode mode = ValidationMode.Default, Func<ValidationContext, ValidationContext>? updater = null) Schema? schema = null,
ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null,
IValidatorsFactory? factory = null)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater);
var validator = new ContentValidator(partitionResolver, context, Enumerable.Repeat(Factory, 1)); var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log);
await validator.ValidateInputAsync(data); await validator.ValidateInputAsync(data);
@ -88,7 +103,22 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
}; };
} }
public static ValidationContext CreateContext(Schema? schema = null, ValidationMode mode = ValidationMode.Default, Func<ValidationContext, ValidationContext>? updater = null) private static IEnumerable<IValidatorsFactory> Factories(IValidatorsFactory? factory)
{
var result = Enumerable.Repeat(Factory, 1);
if (factory != null)
{
result = result.Union(Enumerable.Repeat(factory, 1));
}
return result;
}
public static ValidationContext CreateContext(
Schema? schema = null,
ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null)
{ {
var context = new ValidationContext( var context = new ValidationContext(
AppId, AppId,

20
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs

@ -38,10 +38,28 @@ namespace Squidex.Domain.Apps.Entities.Assets
sut = new RecursiveDeleter(commandBus, assetRepository, assetFolderRepository, typeNameRegistry, log); sut = new RecursiveDeleter(commandBus, assetRepository, assetFolderRepository, typeNameRegistry, log);
} }
[Fact]
public void Should_return_assets_filter_for_events_filter()
{
IEventConsumer consumer = sut;
Assert.Equal("^assetFolder\\-", consumer.EventsFilter);
}
[Fact] [Fact]
public async Task Should_do_nothing_on_clear() public async Task Should_do_nothing_on_clear()
{ {
await sut.ClearAsync(); IEventConsumer consumer = sut;
await consumer.ClearAsync();
}
[Fact]
public void Should_return_type_name_for_name()
{
IEventConsumer consumer = sut;
Assert.Equal(typeof(RecursiveDeleter).Name, consumer.Name);
} }
[Fact] [Fact]

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs

@ -104,7 +104,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
patched = patch.MergeInto(data); patched = patch.MergeInto(data);
var context = new ContentOperationContext(appProvider, Enumerable.Repeat(new DefaultValidatorsFactory(), 1), scriptEngine); var context = new ContentOperationContext(appProvider, Enumerable.Repeat(new DefaultValidatorsFactory(), 1), scriptEngine, A.Fake<ISemanticLog>());
sut = new ContentDomainObject(Store, contentWorkflow, context, A.Dummy<ISemanticLog>()); sut = new ContentDomainObject(Store, contentWorkflow, context, A.Dummy<ISemanticLog>());
sut.Setup(Id); sut.Setup(Id);

92
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/CachingTextIndexerStateTests.cs

@ -5,9 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Entities.Contents.Text.State; using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Xunit; using Xunit;
@ -28,60 +30,100 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
[Fact] [Fact]
public async Task Should_retrieve_from_inner_when_not_cached() public async Task Should_retrieve_from_inner_when_not_cached()
{ {
var state = new TextContentState { ContentId = contentId }; var contentIds = HashSet.Of(contentId);
A.CallTo(() => inner.GetAsync(appId, contentId)) var state = new TextContentState { UniqueContentId = contentId };
.Returns(state);
var found1 = await sut.GetAsync(appId, contentId); var states = new Dictionary<DomainId, TextContentState>
var found2 = await sut.GetAsync(appId, contentId); {
[contentId] = state
};
Assert.Same(state, found1); A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>.That.Is(contentIds)))
Assert.Same(state, found2); .Returns(states);
A.CallTo(() => inner.GetAsync(appId, contentId)) var found1 = await sut.GetAsync(HashSet.Of(contentId));
var found2 = await sut.GetAsync(HashSet.Of(contentId));
Assert.Same(state, found1[contentId]);
Assert.Same(state, found2[contentId]);
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>.That.Is(contentIds)))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_retrieve_from_inner_when_not_cached_and_not_found()
{
var contentIds = HashSet.Of(contentId);
var state = new TextContentState { UniqueContentId = contentId };
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>.That.Is(contentIds)))
.Returns(new Dictionary<DomainId, TextContentState>());
var found1 = await sut.GetAsync(HashSet.Of(contentId));
var found2 = await sut.GetAsync(HashSet.Of(contentId));
Assert.Empty(found1);
Assert.Empty(found2);
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>.That.Is(contentIds)))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
public async Task Should_not_retrieve_from_inner_when_cached() public async Task Should_not_retrieve_from_inner_when_cached()
{ {
var state = new TextContentState { ContentId = contentId }; var contentIds = HashSet.Of(contentId);
var state = new TextContentState { UniqueContentId = contentId };
await sut.SetAsync(appId, state); await sut.SetAsync(new List<TextContentState>
{
state
});
var found1 = await sut.GetAsync(appId, contentId); var found1 = await sut.GetAsync(contentIds);
var found2 = await sut.GetAsync(appId, contentId); var found2 = await sut.GetAsync(contentIds);
Assert.Same(state, found1); Assert.Same(state, found1[contentId]);
Assert.Same(state, found2); Assert.Same(state, found2[contentId]);
A.CallTo(() => inner.SetAsync(appId, state)) A.CallTo(() => inner.SetAsync(A<List<TextContentState>>.That.IsSameSequenceAs(state)))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => inner.GetAsync(appId, contentId)) A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_retrieve_from_inner_when_removed() public async Task Should_not_retrieve_from_inner_when_removed()
{ {
var state = new TextContentState { ContentId = contentId }; var contentIds = HashSet.Of(contentId);
var state = new TextContentState { UniqueContentId = contentId };
await sut.SetAsync(appId, state); await sut.SetAsync(new List<TextContentState>
{
state
});
await sut.RemoveAsync(appId, contentId); await sut.SetAsync(new List<TextContentState>
{
new TextContentState { UniqueContentId = contentId, IsDeleted = true }
});
var found1 = await sut.GetAsync(appId, contentId); var found1 = await sut.GetAsync(contentIds);
var found2 = await sut.GetAsync(appId, contentId); var found2 = await sut.GetAsync(contentIds);
Assert.Null(found1); Assert.Empty(found1);
Assert.Null(found2); Assert.Empty(found2);
A.CallTo(() => inner.RemoveAsync(appId, contentId)) A.CallTo(() => inner.SetAsync(A<List<TextContentState>>.That.Matches(x => x.Count == 1 && x[0].IsDeleted)))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => inner.GetAsync(appId, contentId)) A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
} }

56
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/DocValuesTests.cs

@ -1,56 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Lucene.Net.Util;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public class DocValuesTests
{
[Fact]
public void Should_read_and_write_doc_values()
{
var version = LuceneVersion.LUCENE_48;
var indexWriter =
new IndexWriter(new RAMDirectory(),
new IndexWriterConfig(version, new StandardAnalyzer(version)));
using (indexWriter)
{
for (byte i = 0; i < 255; i++)
{
var document = new Document();
document.AddBinaryDocValuesField("field", new BytesRef(new[] { i }));
indexWriter.AddDocument(document);
}
indexWriter.Commit();
using (var reader = indexWriter.GetReader(true))
{
var bytesRef = new BytesRef(1);
for (byte i = 0; i < 255; i++)
{
reader.GetBinaryValue("field", i, bytesRef);
Assert.Equal(i, bytesRef.Bytes[0]);
}
}
}
}
}
}

50
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/LuceneIndexFactory.cs

@ -1,50 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public sealed class LuceneIndexFactory : IIndexerFactory
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IIndexStorage storage;
private LuceneTextIndexGrain grain;
public LuceneIndexFactory(IIndexStorage storage)
{
this.storage = storage;
A.CallTo(() => grainFactory.GetGrain<ILuceneTextIndexGrain>(A<string>._, null))
.ReturnsLazily(() => grain);
}
public async Task<ITextIndex> CreateAsync(DomainId schemaId)
{
var indexManager = new IndexManager(storage, A.Fake<ISemanticLog>());
grain = new LuceneTextIndexGrain(indexManager);
await grain.ActivateAsync(schemaId.ToString());
return new LuceneTextIndex(grainFactory, indexManager);
}
public async Task CleanupAsync()
{
if (grain != null)
{
await grain.OnDeactivateAsync();
}
}
}
}

49
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TestStorages.cs

@ -1,49 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene.Storage;
using Squidex.Domain.Apps.Entities.MongoDb.FullText;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public static class TestStorages
{
public static IIndexStorage Assets()
{
var storage = new AssetIndexStorage(new MemoryAssetStore());
return storage;
}
public static IIndexStorage TempFolder()
{
var storage = new FileIndexStorage();
return storage;
}
public static IIndexStorage MongoDB()
{
var mongoClient = new MongoClient("mongodb://localhost");
var mongoDatabase = mongoClient.GetDatabase("FullText");
var mongoBucket = new GridFSBucket<string>(mongoDatabase, new GridFSBucketOptions
{
BucketName = $"bucket_{DateTime.UtcNow.Ticks}"
});
var storage = new MongoIndexStorage(mongoBucket);
return storage;
}
}
}

124
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs

@ -36,6 +36,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
public abstract IIndexerFactory Factory { get; } public abstract IIndexerFactory Factory { get; }
public virtual bool SupportsCleanup { get; set; } = false;
public virtual bool SupportsSearchSyntax { get; set; } = true;
public virtual bool SupportsMultiLanguage { get; set; } = true;
public virtual InMemoryTextIndexerState State { get; } = new InMemoryTextIndexerState(); public virtual InMemoryTextIndexerState State { get; } = new InMemoryTextIndexerState();
protected TextIndexerTestsBase() protected TextIndexerTestsBase()
@ -46,39 +52,92 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
Language.EN); Language.EN);
} }
[Fact] [SkippableFact]
public async Task Should_throw_exception_for_invalid_query() public async Task Should_throw_exception_for_invalid_query()
{ {
Skip.IfNot(SupportsSearchSyntax);
await Assert.ThrowsAsync<ValidationException>(async () => await Assert.ThrowsAsync<ValidationException>(async () =>
{ {
await TestCombinations(Search(expected: null, text: "~hello")); await TestCombinations(Search(expected: null, text: "~hello"));
}); });
} }
[Fact] [SkippableFact]
public async Task Should_index_invariant_content_and_retrieve() public async Task Should_index_invariant_content_and_retrieve_with_fuzzy()
{ {
Skip.IfNot(SupportsSearchSyntax);
await TestCombinations( await TestCombinations(
Create(ids1[0], "iv", "Hello"), Create(ids1[0], "iv", "Hello"),
Create(ids2[0], "iv", "World"), Create(ids2[0], "iv", "World"),
Search(expected: ids1, text: "Hello"), Search(expected: ids1, text: "helo~"),
Search(expected: ids2, text: "World"), Search(expected: ids2, text: "wold~", SearchScope.All)
);
}
Search(expected: null, text: "Hello", SearchScope.Published), [SkippableFact]
Search(expected: null, text: "World", SearchScope.Published) public async Task Should_search_by_field()
{
Skip.IfNot(SupportsSearchSyntax);
await TestCombinations(
Create(ids1[0], "en", "City"),
Create(ids2[0], "de", "Stadt"),
Search(expected: ids1, text: "en:city"),
Search(expected: ids2, text: "de:Stadt")
); );
} }
[Fact] [Fact]
public async Task Should_index_invariant_content_and_retrieve_with_fuzzy() public async Task Should_index_localized_content_and_retrieve()
{
if (SupportsMultiLanguage)
{
await TestCombinations(
Create(ids1[0], "de", "Stadt und Land and Fluss"),
Create(ids2[0], "en", "City and Country und River"),
Search(expected: ids1, text: "Stadt"),
Search(expected: ids2, text: "City"),
Search(expected: ids1, text: "and"),
Search(expected: ids2, text: "und")
);
}
else
{
var both = ids2.Union(ids1).ToList();
await TestCombinations(
Create(ids1[0], "de", "Stadt und Land and Fluss"),
Create(ids2[0], "en", "City and Country und River"),
Search(expected: ids1, text: "Stadt"),
Search(expected: ids2, text: "City"),
Search(expected: null, text: "and"),
Search(expected: both, text: "und")
);
}
}
[Fact]
public async Task Should_index_invariant_content_and_retrieve()
{ {
await TestCombinations( await TestCombinations(
Create(ids1[0], "iv", "Hello"), Create(ids1[0], "iv", "Hello"),
Create(ids2[0], "iv", "World"), Create(ids2[0], "iv", "World"),
Search(expected: ids1, text: "helo~"), Search(expected: ids1, text: "Hello"),
Search(expected: ids2, text: "wold~", SearchScope.All) Search(expected: ids2, text: "World"),
Search(expected: null, text: "Hello", SearchScope.Published),
Search(expected: null, text: "World", SearchScope.Published)
); );
} }
@ -281,36 +340,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
); );
} }
[Fact]
public async Task Should_search_by_field()
{
await TestCombinations(
Create(ids1[0], "en", "City"),
Create(ids2[0], "de", "Stadt"),
Search(expected: ids1, text: "en:city"),
Search(expected: ids2, text: "de:Stadt")
);
}
[Fact]
public async Task Should_index_localized_content_and_retrieve()
{
await TestCombinations(
Create(ids1[0], "de", "Stadt und Land and Fluss"),
Create(ids2[0], "en", "City and Country und River"),
Search(expected: ids1, text: "Stadt"),
Search(expected: ids1, text: "and"),
Search(expected: ids2, text: "und"),
Search(expected: ids2, text: "City"),
Search(expected: ids2, text: "und"),
Search(expected: ids1, text: "and")
);
}
private IndexOperation Create(DomainId id, string language, string text) private IndexOperation Create(DomainId id, string language, string text)
{ {
var data = var data =
@ -375,7 +404,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
contentEvent.AppId = appId; contentEvent.AppId = appId;
contentEvent.SchemaId = schemaId; contentEvent.SchemaId = schemaId;
return p => p.On(Envelope.Create(contentEvent)); return p => p.On(Enumerable.Repeat(Envelope.Create<IEvent>(contentEvent), 1));
} }
private IndexOperation Search(List<DomainId>? expected, string text, SearchScope target = SearchScope.All) private IndexOperation Search(List<DomainId>? expected, string text, SearchScope target = SearchScope.All)
@ -384,7 +413,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
{ {
var searchFilter = SearchFilter.ShouldHaveSchemas(schemaId.Id); var searchFilter = SearchFilter.ShouldHaveSchemas(schemaId.Id);
var result = await p.TextIndexer.SearchAsync(text, app, searchFilter, target); var result = await p.TextIndex.SearchAsync(text, app, searchFilter, target);
if (expected != null) if (expected != null)
{ {
@ -399,9 +428,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
private async Task TestCombinations(params IndexOperation[] actions) private async Task TestCombinations(params IndexOperation[] actions)
{ {
for (var i = 0; i < actions.Length; i++) if (SupportsCleanup)
{
for (var i = 0; i < actions.Length; i++)
{
await TestCombinations(i, actions);
}
}
else
{ {
await TestCombinations(i, actions); await TestCombinations(0, actions);
} }
} }

41
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs

@ -0,0 +1,41 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Contents.Text.Elastic;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
[Trait("Category", "Dependencies")]
public class TextIndexerTests_Elastic : TextIndexerTestsBase
{
private sealed class TheFactory : IIndexerFactory
{
public Task CleanupAsync()
{
return Task.CompletedTask;
}
public Task<ITextIndex> CreateAsync(DomainId schemaId)
{
var index = new ElasticSearchTextIndex("http://localhost:9200", "squidex", true);
return Task.FromResult<ITextIndex>(index);
}
}
public override IIndexerFactory Factory { get; } = new TheFactory();
public TextIndexerTests_Elastic()
{
SupportsSearchSyntax = false;
SupportsMultiLanguage = false;
}
}
}

14
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_FS.cs

@ -1,14 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public class TextIndexerTests_FS : TextIndexerTestsBase
{
public override IIndexerFactory Factory { get; } = new LuceneIndexFactory(TestStorages.TempFolder());
}
}

33
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs

@ -5,6 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.MongoDb.FullText;
using Squidex.Infrastructure;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Text namespace Squidex.Domain.Apps.Entities.Contents.Text
@ -12,6 +16,33 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
[Trait("Category", "Dependencies")] [Trait("Category", "Dependencies")]
public class TextIndexerTests_Mongo : TextIndexerTestsBase public class TextIndexerTests_Mongo : TextIndexerTestsBase
{ {
public override IIndexerFactory Factory { get; } = new LuceneIndexFactory(TestStorages.MongoDB()); private sealed class TheFactory : IIndexerFactory
{
private readonly MongoClient mongoClient = new MongoClient("mongodb://localhost");
public Task CleanupAsync()
{
return Task.CompletedTask;
}
public async Task<ITextIndex> CreateAsync(DomainId schemaId)
{
var database = mongoClient.GetDatabase("FullText");
var index = new MongoTextIndex(database, false);
await index.InitializeAsync();
return index;
}
}
public override IIndexerFactory Factory { get; } = new TheFactory();
public TextIndexerTests_Mongo()
{
SupportsSearchSyntax = false;
SupportsMultiLanguage = false;
}
} }
} }

18
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs

@ -51,21 +51,27 @@ namespace Squidex.Domain.Apps.Entities.Rules
} }
[Fact] [Fact]
public void Should_return_contents_filter_for_events_filter() public void Should_return_wildcard_filter_for_events_filter()
{ {
Assert.Equal(".*", sut.EventsFilter); IEventConsumer consumer = sut;
Assert.Equal(".*", consumer.EventsFilter);
} }
[Fact] [Fact]
public void Should_return_type_name_for_name() public async Task Should_do_nothing_on_clear()
{ {
Assert.Equal(nameof(RuleEnqueuer), sut.Name); IEventConsumer consumer = sut;
await consumer.ClearAsync();
} }
[Fact] [Fact]
public async Task Should_do_nothing_on_clear() public void Should_return_type_name_for_name()
{ {
await sut.ClearAsync(); IEventConsumer consumer = sut;
Assert.Equal(typeof(RuleEnqueuer).Name, consumer.Name);
} }
[Fact] [Fact]

1
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -32,6 +32,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<CodeAnalysisRuleSet>..\..\Squidex.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>..\..\Squidex.ruleset</CodeAnalysisRuleSet>

10
backend/tests/Squidex.Infrastructure.Tests/Commands/CommandRequestTests.cs

@ -52,8 +52,8 @@ namespace Squidex.Infrastructure.Commands
var sut = CommandRequest.Create(null!); var sut = CommandRequest.Create(null!);
Assert.Same(culture.Name, sut.Culture); Assert.Equal(culture.Name, sut.Culture);
Assert.Same(cultureUI.Name, sut.CultureUI); Assert.Equal(cultureUI.Name, sut.CultureUI);
} }
[Fact, Trait("Category", "Dependencies")] [Fact, Trait("Category", "Dependencies")]
@ -76,10 +76,10 @@ namespace Squidex.Infrastructure.Commands
var request = CommandRequest.Create(null!); var request = CommandRequest.Create(null!);
var cultureFromGrain = await grain.GetCultureAsync(request); var cultureFromGrain = await grain.GetCultureAsync(request);
var cultureUIFromGrain = await grain.GetCultureAsync(request); var cultureUIFromGrain = await grain.GetCultureUIAsync(request);
Assert.Same(culture.Name, cultureFromGrain); Assert.Equal(culture.Name, cultureFromGrain);
Assert.Same(cultureUI.Name, cultureUIFromGrain); Assert.Equal(cultureUI.Name, cultureUIFromGrain);
} }
} }
} }

131
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CompoundEventConsumerTests.cs

@ -1,131 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Infrastructure.TestHelpers;
using Xunit;
namespace Squidex.Infrastructure.EventSourcing
{
public class CompoundEventConsumerTests
{
private readonly IEventConsumer consumer1 = A.Fake<IEventConsumer>();
private readonly IEventConsumer consumer2 = A.Fake<IEventConsumer>();
[Fact]
public void Should_return_given_name()
{
var sut = new CompoundEventConsumer("consumer-name", consumer1);
Assert.Equal("consumer-name", sut.Name);
}
[Fact]
public void Should_return_first_inner_name()
{
A.CallTo(() => consumer1.Name).Returns("my-inner-consumer");
var sut = new CompoundEventConsumer(consumer1, consumer2);
Assert.Equal("my-inner-consumer", sut.Name);
}
[Fact]
public void Should_return_compound_filter()
{
A.CallTo(() => consumer1.EventsFilter).Returns("filter1");
A.CallTo(() => consumer2.EventsFilter).Returns("filter2");
var sut = new CompoundEventConsumer("my", consumer1, consumer2);
Assert.Equal("(filter1)|(filter2)", sut.EventsFilter);
}
[Fact]
public void Should_return_compound_filter_from_array()
{
A.CallTo(() => consumer1.EventsFilter).Returns("filter1");
A.CallTo(() => consumer2.EventsFilter).Returns("filter2");
var sut = new CompoundEventConsumer(new[] { consumer1, consumer2 });
Assert.Equal("(filter1)|(filter2)", sut.EventsFilter);
}
[Fact]
public void Should_ignore_empty_filters()
{
A.CallTo(() => consumer1.EventsFilter).Returns("filter1");
A.CallTo(() => consumer2.EventsFilter).Returns(string.Empty);
var sut = new CompoundEventConsumer("my", consumer1, consumer2);
Assert.Equal("(filter1)", sut.EventsFilter);
}
[Fact]
public async Task Should_clear_all_consumers()
{
var sut = new CompoundEventConsumer("consumer-name", consumer1, consumer2);
await sut.ClearAsync();
A.CallTo(() => consumer1.ClearAsync()).MustHaveHappened();
A.CallTo(() => consumer2.ClearAsync()).MustHaveHappened();
}
[Fact]
public async Task Should_invoke_all_consumers()
{
var @event = Envelope.Create<IEvent>(new MyEvent());
var sut = new CompoundEventConsumer("consumer-name", consumer1, consumer2);
await sut.On(@event);
A.CallTo(() => consumer1.On(@event)).MustHaveHappened();
A.CallTo(() => consumer2.On(@event)).MustHaveHappened();
}
[Fact]
public void Should_handle_if_any_consumer_handles()
{
var stored = new StoredEvent("Stream", "1", 1, new EventData("Type", new EnvelopeHeaders(), "Payload"));
A.CallTo(() => consumer1.Handles(stored))
.Returns(false);
A.CallTo(() => consumer2.Handles(stored))
.Returns(true);
var sut = new CompoundEventConsumer("consumer-name", consumer1, consumer2);
var result = sut.Handles(stored);
Assert.True(result);
}
[Fact]
public void Should_no_handle_if_no_consumer_handles()
{
var stored = new StoredEvent("Stream", "1", 1, new EventData("Type", new EnvelopeHeaders(), "Payload"));
A.CallTo(() => consumer1.Handles(stored))
.Returns(false);
A.CallTo(() => consumer2.Handles(stored))
.Returns(false);
var sut = new CompoundEventConsumer("consumer-name", consumer1, consumer2);
var result = sut.Handles(stored);
Assert.False(result);
}
}
}

5
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs

@ -335,10 +335,7 @@ namespace Squidex.Infrastructure.EventSourcing
} }
finally finally
{ {
if (subscription != null) subscription?.Unsubscribe();
{
await subscription.StopAsync();
}
} }
} }

198
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs

@ -6,10 +6,10 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using FluentAssertions; using FluentAssertions;
using Orleans.Concurrency;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
@ -22,6 +22,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
public sealed class MyEventConsumerGrain : EventConsumerGrain public sealed class MyEventConsumerGrain : EventConsumerGrain
{ {
private IEventSubscriber currentSubscriber;
public MyEventConsumerGrain( public MyEventConsumerGrain(
EventConsumerFactory eventConsumerFactory, EventConsumerFactory eventConsumerFactory,
IGrainState<EventConsumerState> state, IGrainState<EventConsumerState> state,
@ -32,14 +34,26 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
} }
protected override IEventConsumerGrain GetSelf() public Task OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent)
{ {
return this; return currentSubscriber.OnEventAsync(subscription, storedEvent);
} }
protected override IEventSubscription CreateSubscription(IEventStore store, IEventSubscriber subscriber, string? filter, string? position) public Task OnErrorAsync(IEventSubscription subscription, Exception exception)
{ {
return store.CreateSubscription(subscriber, filter, position); return currentSubscriber.OnErrorAsync(subscription, exception);
}
protected override IEventSubscription CreateRetrySubscription(IEventSubscriber subscriber)
{
return CreateSubscription(subscriber);
}
protected override IEventSubscription CreateSubscription(IEventSubscriber subscriber)
{
currentSubscriber = subscriber;
return base.CreateSubscription(subscriber);
} }
} }
@ -51,7 +65,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
private readonly IEventDataFormatter formatter = A.Fake<IEventDataFormatter>(); private readonly IEventDataFormatter formatter = A.Fake<IEventDataFormatter>();
private readonly EventData eventData = new EventData("Type", new EnvelopeHeaders(), "Payload"); private readonly EventData eventData = new EventData("Type", new EnvelopeHeaders(), "Payload");
private readonly Envelope<IEvent> envelope = new Envelope<IEvent>(new MyEvent()); private readonly Envelope<IEvent> envelope = new Envelope<IEvent>(new MyEvent());
private readonly EventConsumerGrain sut; private readonly MyEventConsumerGrain sut;
private readonly string consumerName; private readonly string consumerName;
private readonly string initialPosition = Guid.NewGuid().ToString(); private readonly string initialPosition = Guid.NewGuid().ToString();
@ -70,6 +84,18 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
A.CallTo(() => eventConsumer.Handles(A<StoredEvent>._)) A.CallTo(() => eventConsumer.Handles(A<StoredEvent>._))
.Returns(true); .Returns(true);
A.CallTo(() => eventConsumer.On(A<IEnumerable<Envelope<IEvent>>>._))
.Invokes((IEnumerable<Envelope<IEvent>> events) =>
{
foreach (var @event in events)
{
eventConsumer.On(@event).Wait();
}
});
A.CallTo(() => eventSubscription.Sender)
.Returns(eventSubscription);
A.CallTo(() => formatter.Parse(eventData)) A.CallTo(() => formatter.Parse(eventData))
.Returns(envelope); .Returns(envelope);
@ -89,6 +115,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.ActivateAsync(consumerName); await sut.ActivateAsync(consumerName);
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = null });
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
@ -101,10 +129,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.ActivateAsync(consumerName); await sut.ActivateAsync(consumerName);
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null });
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -115,10 +145,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.ActivateAsync(consumerName); await sut.ActivateAsync(consumerName);
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null });
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -127,10 +159,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.ActivateAsync(consumerName); await sut.ActivateAsync(consumerName);
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null });
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -141,13 +175,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.StopAsync(); await sut.StopAsync();
await sut.StopAsync(); await sut.StopAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = null });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -158,22 +194,24 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.StopAsync(); await sut.StopAsync();
await sut.ResetAsync(); await sut.ResetAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = null, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = null, Error = null });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(2, Times.Exactly); .MustHaveHappened(2, Times.Exactly);
A.CallTo(() => eventConsumer.ClearAsync()) A.CallTo(() => eventConsumer.ClearAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, grainState.Value.Position)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, grainState.Value.Position))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, null)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, null))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -186,13 +224,71 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(eventSubscription, @event); await OnEventAsync(eventSubscription, @event);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 1 }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 1 });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventConsumer.On(envelope)) A.CallTo(() => eventConsumer.On(envelope))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_invoke_and_update_position_when_event_received_one_by_one()
{
var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData);
A.CallTo(() => eventConsumer.BatchSize)
.Returns(1);
await sut.ActivateAsync(consumerName);
await sut.ActivateAsync();
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 5 });
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(5, Times.Exactly);
A.CallTo(() => eventConsumer.On(A<IEnumerable<Envelope<IEvent>>>._))
.MustHaveHappened(5, Times.Exactly);
}
[Fact]
public async Task Should_invoke_and_update_position_when_event_received_batched()
{
var @event = new StoredEvent("Stream", Guid.NewGuid().ToString(), 123, eventData);
A.CallTo(() => eventConsumer.BatchSize)
.Returns(100);
await sut.ActivateAsync(consumerName);
await sut.ActivateAsync();
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await OnEventAsync(eventSubscription, @event);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 5 });
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedOnceExactly();
A.CallTo(() => eventConsumer.On(A<IEnumerable<Envelope<IEvent>>>._))
.MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -208,10 +304,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(eventSubscription, @event); await OnEventAsync(eventSubscription, @event);
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 1 }); await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 0 });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventConsumer.On(envelope)) A.CallTo(() => eventConsumer.On(envelope))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -230,10 +328,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(eventSubscription, @event); await OnEventAsync(eventSubscription, @event);
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 1 }); await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = @event.EventPosition, Error = null, Count = 0 });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventConsumer.On(envelope)) A.CallTo(() => eventConsumer.On(envelope))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -249,6 +349,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(A.Fake<IEventSubscription>(), @event); await OnEventAsync(A.Fake<IEventSubscription>(), @event);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null });
A.CallTo(() => eventConsumer.On(envelope)) A.CallTo(() => eventConsumer.On(envelope))
@ -265,13 +367,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnErrorAsync(eventSubscription, ex); await OnErrorAsync(eventSubscription, ex);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() }); AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -284,6 +388,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnErrorAsync(A.Fake<IEventSubscription>(), ex); await OnErrorAsync(A.Fake<IEventSubscription>(), ex);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null }); AssetGrainState(new EventConsumerState { IsStopped = false, Position = initialPosition, Error = null });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
@ -297,6 +403,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.CompleteAsync();
A.CallTo(() => eventSubscription.WakeUp()) A.CallTo(() => eventSubscription.WakeUp())
.MustHaveHappened(); .MustHaveHappened();
} }
@ -313,13 +421,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await sut.ActivateAsync(); await sut.ActivateAsync();
await sut.ResetAsync(); await sut.ResetAsync();
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() }); AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() });
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -337,16 +447,18 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(eventSubscription, @event); await OnEventAsync(eventSubscription, @event);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() }); AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() });
A.CallTo(() => eventConsumer.On(envelope)) A.CallTo(() => eventConsumer.On(envelope))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -364,16 +476,18 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(eventSubscription, @event); await OnEventAsync(eventSubscription, @event);
await sut.CompleteAsync();
AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() }); AssetGrainState(new EventConsumerState { IsStopped = true, Position = initialPosition, Error = ex.ToString() });
A.CallTo(() => eventConsumer.On(envelope)) A.CallTo(() => eventConsumer.On(envelope))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -391,6 +505,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await OnEventAsync(eventSubscription, @event); await OnEventAsync(eventSubscription, @event);
await sut.CompleteAsync();
await sut.StopAsync(); await sut.StopAsync();
await sut.StartAsync(); await sut.StartAsync();
await sut.StartAsync(); await sut.StartAsync();
@ -403,21 +519,21 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
A.CallTo(() => grainState.WriteAsync()) A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened(2, Times.Exactly); .MustHaveHappened(2, Times.Exactly);
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.MustHaveHappened(2, Times.Exactly); .MustHaveHappened(2, Times.Exactly);
} }
private Task OnErrorAsync(IEventSubscription subscriber, Exception ex) private Task OnErrorAsync(IEventSubscription subscription, Exception exception)
{ {
return sut.OnErrorAsync(subscriber.AsImmutable(), ex.AsImmutable()); return sut.OnErrorAsync(subscription, exception);
} }
private Task OnEventAsync(IEventSubscription subscriber, StoredEvent ev) private Task OnEventAsync(IEventSubscription subscription, StoredEvent ev)
{ {
return sut.OnEventAsync(subscriber.AsImmutable(), ev.AsImmutable()); return sut.OnEventAsync(subscription, ev);
} }
private void AssetGrainState(EventConsumerState state) private void AssetGrainState(EventConsumerState state)

9
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs

@ -27,7 +27,7 @@ namespace Squidex.Infrastructure.EventSourcing
mongoClient = new MongoClient(connectionString); mongoClient = new MongoClient(connectionString);
mongoDatabase = mongoClient.GetDatabase($"EventStoreTest"); mongoDatabase = mongoClient.GetDatabase($"EventStoreTest");
Dispose(); Cleanup();
BsonJsonConvention.Register(JsonSerializer.Create(JsonHelper.DefaultSettings())); BsonJsonConvention.Register(JsonSerializer.Create(JsonHelper.DefaultSettings()));
@ -35,10 +35,15 @@ namespace Squidex.Infrastructure.EventSourcing
EventStore.InitializeAsync().Wait(); EventStore.InitializeAsync().Wait();
} }
public void Dispose() public void Cleanup()
{ {
mongoClient.DropDatabase("EventStoreTest"); mongoClient.DropDatabase("EventStoreTest");
} }
public void Dispose()
{
Cleanup();
}
} }
public sealed class MongoEventStoreDirectFixture : MongoEventStoreFixture public sealed class MongoEventStoreDirectFixture : MongoEventStoreFixture

26
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs

@ -164,21 +164,6 @@ namespace Squidex.Infrastructure.EventSourcing
: base(eventConsumerFactory, state, eventStore, eventDataFormatter, log) : base(eventConsumerFactory, state, eventStore, eventDataFormatter, log)
{ {
} }
protected override IEventConsumerGrain GetSelf()
{
return this;
}
protected override TaskScheduler GetScheduler()
{
return scheduler;
}
protected override IEventSubscription CreateSubscription(IEventStore store, IEventSubscriber subscriber, string? filter, string? position)
{
return store.CreateSubscription(subscriber, filter, position);
}
} }
public class MyEvent : IEvent public class MyEvent : IEvent
@ -206,16 +191,6 @@ namespace Squidex.Infrastructure.EventSourcing
this.expectedCount = expectedCount; this.expectedCount = expectedCount;
} }
public Task ClearAsync()
{
return Task.CompletedTask;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public async Task On(Envelope<IEvent> @event) public async Task On(Envelope<IEvent> @event)
{ {
Received++; Received++;
@ -237,6 +212,7 @@ namespace Squidex.Infrastructure.EventSourcing
public MongoParallelInsertTests(MongoEventStoreReplicaSetFixture fixture) public MongoParallelInsertTests(MongoEventStoreReplicaSetFixture fixture)
{ {
_ = fixture; _ = fixture;
_.Cleanup();
var typeNameRegistry = new TypeNameRegistry().Map(typeof(MyEvent), "My"); var typeNameRegistry = new TypeNameRegistry().Map(typeof(MyEvent), "My");

4
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs

@ -27,7 +27,7 @@ namespace Squidex.Infrastructure.EventSourcing
await WaitAndStopAsync(sut); await WaitAndStopAsync(sut);
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._)) A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._))
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
@ -95,7 +95,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
await Task.Delay(200); await Task.Delay(200);
await sut.StopAsync(); sut.Unsubscribe();
} }
} }
} }

67
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs

@ -19,23 +19,34 @@ namespace Squidex.Infrastructure.EventSourcing
private readonly IEventSubscription eventSubscription = A.Fake<IEventSubscription>(); private readonly IEventSubscription eventSubscription = A.Fake<IEventSubscription>();
private readonly IEventSubscriber sutSubscriber; private readonly IEventSubscriber sutSubscriber;
private readonly RetrySubscription sut; private readonly RetrySubscription sut;
private readonly string streamFilter = Guid.NewGuid().ToString();
public RetrySubscriptionTests() public RetrySubscriptionTests()
{ {
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)).Returns(eventSubscription); A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.Returns(eventSubscription);
A.CallTo(() => eventSubscription.Sender)
.Returns(eventSubscription);
sut = new RetrySubscription(eventStore, eventSubscriber, streamFilter, null) { ReconnectWaitMs = 50 }; sut = new RetrySubscription(eventSubscriber, s => eventStore.CreateSubscription(s)) { ReconnectWaitMs = 50 };
sutSubscriber = sut; sutSubscriber = sut;
} }
[Fact] [Fact]
public async Task Should_subscribe_after_constructor() public void Should_return_original_subscription_as_sender()
{
var sender = sut.Sender;
Assert.Same(eventSubscription, sender);
}
[Fact]
public void Should_subscribe_after_constructor()
{ {
await sut.StopAsync(); sut.Unsubscribe();
A.CallTo(() => eventStore.CreateSubscription(sut, streamFilter, null)) A.CallTo(() => eventStore.CreateSubscription(sut, A<string>._, A<string>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -46,15 +57,15 @@ namespace Squidex.Infrastructure.EventSourcing
await Task.Delay(1000); await Task.Delay(1000);
await sut.StopAsync(); sut.Unsubscribe();
A.CallTo(() => eventSubscription.StopAsync()) A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(2, Times.Exactly); .MustHaveHappened(2, Times.Exactly);
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._)) A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.MustHaveHappened(2, Times.Exactly); .MustHaveHappened(2, Times.Exactly);
A.CallTo(() => eventSubscriber.OnErrorAsync(A<IEventSubscription>._, A<Exception>._)) A.CallTo(() => eventSubscriber.OnErrorAsync(eventSubscription, A<Exception>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -64,26 +75,28 @@ namespace Squidex.Infrastructure.EventSourcing
var ex = new InvalidOperationException(); var ex = new InvalidOperationException();
await OnErrorAsync(eventSubscription, ex); await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(null!, ex); await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(null!, ex); await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(null!, ex); await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(null!, ex); await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(null!, ex); await OnErrorAsync(eventSubscription, ex);
await sut.StopAsync();
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnErrorAsync(sut, ex))
A.CallTo(() => eventSubscriber.OnErrorAsync(eventSubscription, ex))
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_forward_error_when_exception_is_from_another_subscription() public async Task Should_not_forward_error_when_exception_is_raised_after_unsubscribe()
{ {
var ex = new InvalidOperationException(); var ex = new InvalidOperationException();
await OnErrorAsync(A.Fake<IEventSubscription>(), ex); await OnErrorAsync(eventSubscription, ex);
await sut.StopAsync();
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnErrorAsync(A<IEventSubscription>._, A<Exception>._)) A.CallTo(() => eventSubscriber.OnErrorAsync(eventSubscription, A<Exception>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -93,22 +106,24 @@ namespace Squidex.Infrastructure.EventSourcing
var ev = new StoredEvent("Stream", "1", 2, new EventData("Type", new EnvelopeHeaders(), "Payload")); var ev = new StoredEvent("Stream", "1", 2, new EventData("Type", new EnvelopeHeaders(), "Payload"));
await OnEventAsync(eventSubscription, ev); await OnEventAsync(eventSubscription, ev);
await sut.StopAsync();
A.CallTo(() => eventSubscriber.OnEventAsync(sut, ev)) sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnEventAsync(eventSubscription, ev))
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_forward_event_when_message_is_from_another_subscription() public async Task Should_forward_event_when_message_is_from_another_subscription()
{ {
var ev = new StoredEvent("Stream", "1", 2, new EventData("Type", new EnvelopeHeaders(), "Payload")); var ev = new StoredEvent("Stream", "1", 2, new EventData("Type", new EnvelopeHeaders(), "Payload"));
await OnEventAsync(A.Fake<IEventSubscription>(), ev); await OnEventAsync(A.Fake<IEventSubscription>(), ev);
await sut.StopAsync();
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnEventAsync(A<IEventSubscription>._, A<StoredEvent>._)) A.CallTo(() => eventSubscriber.OnEventAsync(A<IEventSubscription>._, A<StoredEvent>._))
.MustNotHaveHappened(); .MustHaveHappened();
} }
private Task OnErrorAsync(IEventSubscription subscriber, Exception ex) private Task OnErrorAsync(IEventSubscription subscriber, Exception ex)

4
backend/tests/Squidex.Infrastructure.Tests/Migrations/MigratorTests.cs

@ -149,9 +149,9 @@ namespace Squidex.Infrastructure.Migrations
await Task.WhenAll(Enumerable.Repeat(0, 10).Select(x => Task.Run(() => sut.MigrateAsync()))); await Task.WhenAll(Enumerable.Repeat(0, 10).Select(x => Task.Run(() => sut.MigrateAsync())));
A.CallTo(() => migrator_0_1.UpdateAsync()) A.CallTo(() => migrator_0_1.UpdateAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
A.CallTo(() => migrator_1_2.UpdateAsync()) A.CallTo(() => migrator_1_2.UpdateAsync())
.MustHaveHappened(1, Times.Exactly); .MustHaveHappenedOnceExactly();
} }
private IMigration BuildMigration(int fromVersion, int toVersion) private IMigration BuildMigration(int fromVersion, int toVersion)

8
backend/tests/Squidex.Infrastructure.Tests/MongoDb/MongoExtensionsTests.cs

@ -78,7 +78,7 @@ namespace Squidex.Infrastructure.MongoDb
var cursor = new Cursor<int>().Add(0, 1, 2, 3, 4, 5); var cursor = new Cursor<int>().Add(0, 1, 2, 3, 4, 5);
await cursor.ForEachPipelineAsync(x => await cursor.ForEachPipedAsync(x =>
{ {
result.Add(x); result.Add(x);
return Task.CompletedTask; return Task.CompletedTask;
@ -98,7 +98,7 @@ namespace Squidex.Infrastructure.MongoDb
{ {
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
{ {
return cursor.ForEachPipelineAsync(x => return cursor.ForEachPipedAsync(x =>
{ {
result.Add(x); result.Add(x);
return Task.CompletedTask; return Task.CompletedTask;
@ -120,7 +120,7 @@ namespace Squidex.Infrastructure.MongoDb
{ {
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
{ {
return cursor.ForEachPipelineAsync(x => return cursor.ForEachPipedAsync(x =>
{ {
if (x == 2) if (x == 2)
{ {
@ -147,7 +147,7 @@ namespace Squidex.Infrastructure.MongoDb
{ {
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
{ {
return cursor.ForEachPipelineAsync(x => return cursor.ForEachPipedAsync(x =>
{ {
if (x == 2) if (x == 2)
{ {

56
backend/tests/Squidex.Infrastructure.Tests/Tasks/SingleThreadedDispatcherTests.cs

@ -1,56 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
namespace Squidex.Infrastructure.Tasks
{
public class SingleThreadedDispatcherTests
{
private readonly SingleThreadedDispatcher sut = new SingleThreadedDispatcher();
[Fact]
public async Task Should_handle_with_task_messages_sequentially()
{
var source = Enumerable.Range(1, 100);
var target = new List<int>();
foreach (var item in source)
{
sut.DispatchAsync(() =>
{
target.Add(item);
return Task.CompletedTask;
}).Forget();
}
await sut.StopAndWaitAsync();
Assert.Equal(source, target);
}
[Fact]
public async Task Should_handle_messages_sequentially()
{
var source = Enumerable.Range(1, 100);
var target = new List<int>();
foreach (var item in source)
{
sut.DispatchAsync(() => target.Add(item)).Forget();
}
await sut.StopAndWaitAsync();
Assert.Equal(source, target);
}
}
}
Loading…
Cancel
Save