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. 23
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AggregateValidator.cs
  6. 21
      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. 57
      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. 65
      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. 34
      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. 252
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  41. 49
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
  42. 116
      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. 151
      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. 91
      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. 120
      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.charactersBetween": "Must have between {min} and {max} character(s).",
"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.extension": "Must be an allowed extension.",
"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 writesBatches = new List<WriteModel<BsonDocument>>();
var writes = new List<WriteModel<BsonDocument>>();
async Task WriteAsync(WriteModel<BsonDocument>? model, bool force)
{
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.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation;
#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 ValidationContext context;
private readonly IEnumerable<IValidatorsFactory> factories;
private readonly ISemanticLog log;
private readonly ConcurrentBag<ValidationError> errors = new ConcurrentBag<ValidationError>();
public IReadOnlyCollection<ValidationError> Errors
@ -32,7 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
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(factories, nameof(factories));
@ -40,6 +42,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
this.context = context;
this.factories = factories;
this.log = log;
this.partitionResolver = partitionResolver;
}
@ -72,7 +75,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
{
Guard.NotNull(data, nameof(data));
var validator = new AggregateValidator(CreateContentValidators());
var validator = new AggregateValidator(CreateContentValidators(), log);
return validator.ValidateAsync(data, context, AddError);
}
@ -108,7 +111,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
return new AggregateValidator(
CreateFieldValidators(field)
.Union(Enumerable.Repeat(
new ObjectValidator<IJsonValue>(fieldsValidators, isPartial, typeName), 1)));
new ObjectValidator<IJsonValue>(fieldsValidators, isPartial, typeName), 1)), log);
}
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)
{
AppId = appId;
ContentId = contentId;
IsOptional = isOptional;
Mode = mode;
Path = path;
Schema = schema;
SchemaId = schemaId;
IsOptional = isOptional;
Path = path;
}
public ValidationContext Optimized(bool isOptimized = true)
@ -68,14 +72,14 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
return Clone(Path, IsOptional, mode);
}
public ValidationContext Optional(bool isOptional)
public ValidationContext Optional(bool fieldIsOptional)
{
if (IsOptional == isOptional)
if (IsOptional == fieldIsOptional)
{
return this;
}
return Clone(Path, isOptional, Mode);
return Clone(Path, fieldIsOptional, Mode);
}
public ValidationContext Nested(string property)

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

@ -5,29 +5,44 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{
public sealed class AggregateValidator : IValidator
{
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.log = log;
}
public Task ValidateAsync(object? value, ValidationContext context, AddError addError)
public async Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{
try
{
if (validators?.Length > 0)
{
return Task.WhenAll(validators.Select(x => x.ValidateAsync(value, context, addError)));
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"));
}
}
}
}

21
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
{
private readonly IValidator[] validators;
private readonly IValidator[]? validators;
private readonly IField field;
public FieldValidator(IEnumerable<IValidator> validators, IField field)
public FieldValidator(IEnumerable<IValidator>? validators, IField field)
{
Guard.NotNull(field, nameof(field));
this.validators = validators.ToArray();
this.validators = validators?.ToArray();
this.field = field;
}
public async Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{
try
{
var typedValue = value;
try
{
if (value is IJsonValue jsonValue)
{
if (jsonValue.Type == JsonValueType.Null)
@ -55,6 +55,12 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
}
}
}
}
catch
{
addError(context.Path, T.Get("contents.validation.invalid"));
return;
}
if (validators?.Length > 0)
{
@ -68,10 +74,5 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
await Task.WhenAll(tasks);
}
}
catch
{
addError(context.Path, T.Get("contents.validation.invalid"));
}
}
}
}

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>())
{
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>())
{
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.
// ==========================================================================
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
};
}
}
}

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

@ -5,7 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure;
@ -13,8 +16,25 @@ using Squidex.Infrastructure.MongoDb;
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)
: base(database, setup)
{
@ -25,28 +45,37 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
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)
{
writes.Add(
new DeleteOneModel<TextContentState>(
Filter.Eq(x => x.UniqueContentId, update.UniqueContentId)));
}
public Task SetAsync(DomainId appId, TextContentState state)
else
{
var documentId = DomainId.Combine(appId, state.ContentId).ToString();
var document = new MongoTextIndexState(documentId, state);
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.Linq;
using System.Threading;
using System.Threading.Tasks;
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)

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;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
public async Task On(Envelope<IEvent> @event)
{
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.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.UsageTracking;
#pragma warning disable CS0649
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 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
{
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)

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

@ -57,11 +57,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
folderDeletedType = typeNameRegistry.GetName<AssetFolderDeleted>();
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
public bool Handles(StoredEvent @event)
{
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) =>
{
w.WriteProperty("action", "retore");
w.WriteProperty("action", "restore");
w.WriteProperty("status", "failed");
w.WriteProperty("operationId", ctx.jobId);
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.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation;
#pragma warning disable IDE0016 // Use 'throw' expression
@ -34,6 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
};
private readonly IScriptEngine scriptEngine;
private readonly ISemanticLog log;
private readonly IAppProvider appProvider;
private readonly IEnumerable<IValidatorsFactory> factories;
private ISchemaEntity schema;
@ -41,11 +43,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
private ContentCommand command;
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.factories = factories;
this.scriptEngine = scriptEngine;
this.log = log;
}
public ISchemaEntity Schema
@ -85,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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);
@ -94,7 +98,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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);
@ -103,7 +107,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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);

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]
public sealed class ElasticSearchTextIndex : ITextIndex
{
private const string IndexName = "contents";
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);
this.indexName = indexName;
this.waitForTesting = waitForTesting;
}
public Task ClearAsync()
@ -33,14 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
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)
{
switch (command)
{
case UpsertIndexEntry upsert:
await UpsertAsync(appId, schemaId, upsert);
await UpsertAsync(upsert);
break;
case UpdateIndexEntry update:
await UpdateAsync(update);
@ -50,23 +58,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
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
{
appId = appId.Id,
appName = appId.Name,
appId = upsert.AppId.Id,
appName = upsert.AppId.Name,
contentId = upsert.ContentId,
schemaId = schemaId.Id,
schemaName = schemaId.Name,
schemaId = upsert.SchemaId.Id,
schemaName = upsert.SchemaId.Name,
serveAll = upsert.ServeAll,
servePublished = upsert.ServePublished,
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)
{
@ -80,12 +93,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
{
doc = new
{
update.ServeAll,
update.ServePublished
serveAll = update.ServeAll,
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)
{
@ -95,7 +108,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
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)
@ -155,7 +168,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
{
var bySchema = new
{
term = new Dictionary<string, object>
terms = new Dictionary<string, object>
{
["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)
{

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 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.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public abstract class IndexCommand
{
public NamedId<DomainId> AppId { get; set; }
public NamedId<DomainId> SchemaId { 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();
}
}

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

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
@ -15,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
public sealed class CachingTextIndexerState : ITextIndexerState
{
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)
{
@ -28,37 +29,69 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
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);
cache.Set((appId, contentId), Tuple.Create(result));
if (missingIds.Count > 0)
{
var fromInner = await inner.GetAsync(missingIds);
return result;
foreach (var (id, state) in fromInner)
{
result[id] = state;
}
public Task SetAsync(DomainId appId, TextContentState state)
foreach (var id in missingIds)
{
Guard.NotNull(state, nameof(state));
var state = fromInner.GetOrDefault(id);
cache.Set((appId, state.ContentId), Tuple.Create<TextContentState?>(state));
cache.Set(id, Tuple.Create<TextContentState?>(state));
}
}
return inner.SetAsync(appId, state);
return result;
}
public Task RemoveAsync(DomainId appId, DomainId contentId)
public Task SetAsync(List<TextContentState> updates)
{
Guard.NotNull(updates, nameof(updates));
foreach (var update in updates)
{
cache.Set((appId, contentId), Tuple.Create<TextContentState?>(null));
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.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure;
@ -12,11 +13,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
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 RemoveAsync(DomainId appId, DomainId contentId);
Task SetAsync(List<TextContentState> updates);
Task ClearAsync();
}

34
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
{
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()
{
@ -22,26 +22,38 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
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)
{
if (states.TryGetValue(id, out var state))
{
return Task.FromResult<TextContentState?>(result);
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;
foreach (var update in updates)
{
if (update.IsDeleted)
{
states.Remove(update.UniqueContentId);
}
public Task RemoveAsync(DomainId appId, DomainId contentId)
else
{
states.Remove((appId, contentId));
states[update.UniqueContentId] = update;
}
}
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 DomainId ContentId { get; set; }
public DomainId UniqueContentId { 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 bool IsDeleted { get; set; }
public void GenerateDocIdNew()
{
if (DocIdCurrent?.EndsWith("_2") != false)
{
DocIdNew = $"{ContentId}_1";
DocIdNew = $"{UniqueContentId}_1";
}
else
{
DocIdNew = $"{ContentId}_2";
DocIdNew = $"{UniqueContentId}_2";
}
}
@ -35,11 +37,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
if (DocIdNew?.EndsWith("_2") != false)
{
DocIdCurrent = $"{ContentId}_1";
DocIdCurrent = $"{UniqueContentId}_1";
}
else
{
DocIdCurrent = $"{ContentId}_2";
DocIdCurrent = $"{UniqueContentId}_2";
}
}
}

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

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

49
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 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)
@ -53,37 +58,47 @@ namespace Squidex.Domain.Apps.Entities.History
this.notifo = notifo;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public Task 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 (historyEvent != null)
if (@event.Payload is 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();
await repository.InsertAsync(historyEvent);
break;
}
}
targets.Add((appEvent, historyEvent));
}
}
if (targets.Count > 0)
{
await notifo.HandleEventsAsync(targets);
await repository.InsertManyAsync(targets.NotNull(x => x.HistoryEvent));
}
}

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

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Core;
@ -117,32 +119,33 @@ namespace Squidex.Domain.Apps.Entities.History
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)
{
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())
{
if (IsTooOld(@event.Headers))
foreach (var @event in publishedEvents)
{
return;
}
var payload = @event.AppEvent.Payload;
if (comment.Mentions == null || comment.Mentions.Length == 0)
if (payload is CommentCreated comment && IsComment(payload))
{
break;
}
using (var stream = client.PublishMany())
{
foreach (var userId in comment.Mentions)
foreach (var userId in comment.Mentions!)
{
var publishRequest = new PublishRequest
{
@ -164,14 +167,48 @@ namespace Squidex.Domain.Apps.Entities.History
await stream.RequestStream.WriteAsync(publishRequest);
}
}
else if (@event.HistoryEvent != null)
{
var historyEvent = @event.HistoryEvent;
var publishRequest = new PublishRequest
{
AppId = options.AppId
};
foreach (var (key, value) in historyEvent.Parameters)
{
publishRequest.Properties.Add(key, value);
}
publishRequest.Properties["SquidexApp"] = payload.AppId.Name;
if (payload is ContentEvent c && !(payload is ContentDeleted))
{
var url = urlGenerator.ContentUI(c.AppId, c.SchemaId, c.ContentId);
publishRequest.Properties["SquidexUrl"] = url;
}
publishRequest.TemplateCode = @event.HistoryEvent.EventType;
SetUser(payload, publishRequest);
SetTopic(payload, publishRequest, historyEvent);
await stream.RequestStream.WriteAsync(publishRequest);
}
}
await stream.RequestStream.CompleteAsync();
await stream.ResponseAsync;
}
break;
}
foreach (var @event in events)
{
switch (@event.AppEvent.Payload)
{
case AppContributorAssigned contributorAssigned:
{
var user = await userResolver.FindByIdAsync(contributorAssigned.ContributorId);
@ -212,6 +249,7 @@ namespace Squidex.Domain.Apps.Entities.History
}
}
}
}
private AllowedTopicRequest BuildAllowedTopicRequest(AppEvent @event, string contributorId)
{
@ -226,52 +264,14 @@ namespace Squidex.Domain.Apps.Entities.History
return topicRequest;
}
public async Task HandleHistoryEventAsync(Envelope<AppEvent> @event, HistoryEvent historyEvent)
{
if (client == null)
{
return;
}
if (IsTooOld(@event.Headers))
private static bool IsTooOld(EnvelopeHeaders headers, Instant now)
{
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);
return now - headers.Timestamp() > MaxAge;
}
private bool IsTooOld(EnvelopeHeaders headers)
private static bool IsComment(AppEvent appEvent)
{
var now = clock.GetCurrentInstant();
return now - headers.Timestamp() > MaxAge;
return appEvent is CommentCreated comment && comment.Mentions?.Length > 0;
}
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 InsertAsync(HistoryEvent item);
Task InsertManyAsync(IEnumerable<HistoryEvent> historyEvents);
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; }
}
public string EventsFilter
{
get { return ".*"; }
}
public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, IRuleEventRepository ruleEventRepository,
RuleService ruleService)
{
@ -55,16 +50,6 @@ namespace Squidex.Domain.Apps.Entities.Rules
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)
{
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>
</PackageReference>
<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.Orleans.CodeGenerator.MSBuild" Version="3.2.2">
<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
{
private readonly TaskCompletionSource<bool> processorStopRequested = new TaskCompletionSource<bool>();
private readonly Task processorTask;
private readonly CosmosDbEventStore store;
private readonly Regex regex;
private readonly string? hostName;
private readonly string hostName;
private readonly IEventSubscriber subscriber;
public CosmosDbSubscription(CosmosDbEventStore store, IEventSubscriber subscriber, string? streamFilter, string? position = null)
@ -42,7 +41,7 @@ namespace Squidex.Infrastructure.EventSourcing
}
else
{
hostName = position;
hostName = position ?? "none";
}
if (!StreamFilter.IsAll(streamFilter))
@ -52,7 +51,7 @@ namespace Squidex.Infrastructure.EventSourcing
this.subscriber = subscriber;
processorTask = Task.Run(async () =>
Task.Run(async () =>
{
try
{
@ -128,7 +127,7 @@ namespace Squidex.Infrastructure.EventSourcing
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 Task StopAsync()
public void Unsubscribe()
{
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.
// ==========================================================================
using System.Threading.Tasks;
using EventStore.ClientAPI;
using EventStore.ClientAPI.Exceptions;
using Squidex.Infrastructure.Json;
@ -44,15 +43,9 @@ namespace Squidex.Infrastructure.EventSourcing
subscription = SubscribeToStream(streamName);
}
public Task StopAsync()
public void Unsubscribe()
{
subscription.Stop();
return Task.CompletedTask;
}
public void WakeUp()
{
}
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.Driver;
using NodaTime;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing
{
@ -19,14 +20,13 @@ namespace Squidex.Infrastructure.EventSourcing
private readonly MongoEventStore eventStore;
private readonly IEventSubscriber eventSubscriber;
private readonly CancellationTokenSource stopToken = new CancellationTokenSource();
private readonly Task task;
public MongoEventStoreSubscription(MongoEventStore eventStore, IEventSubscriber eventSubscriber, string? streamFilter, string? position)
{
this.eventStore = eventStore;
this.eventSubscriber = eventSubscriber;
task = QueryAsync(streamFilter, position);
QueryAsync(streamFilter, position).Forget();
}
private async Task QueryAsync(string? streamFilter, string? position)
@ -155,15 +155,9 @@ namespace Squidex.Infrastructure.EventSourcing
return result;
}
public Task StopAsync()
public void Unsubscribe()
{
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>())
{
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))
{

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

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

@ -71,7 +71,7 @@ namespace Squidex.Infrastructure.States
{
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)
{
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)!;
}
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)
{
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);
}
}
}

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

@ -6,15 +6,13 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing.Grains
{
@ -26,7 +24,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
private readonly IEventStore eventStore;
private readonly ISemanticLog log;
private TaskScheduler? scheduler;
private IEventSubscription? currentSubscription;
private BatchSubscriber? currentSubscriber;
private IEventConsumer? eventConsumer;
private EventConsumerState State
@ -65,6 +63,14 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return Task.CompletedTask;
}
public async Task CompleteAsync()
{
if (currentSubscriber != null)
{
await currentSubscriber.CompleteAsync();
}
}
public Task<Immutable<EventConsumerInfo>> GetStateAsync()
{
return Task.FromResult(CreateInfo());
@ -75,41 +81,23 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return State.ToInfo(eventConsumer!.Name).AsImmutable();
}
public Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent)
{
if (subscription.Value != currentSubscription)
public Task OnEventsAsync(IReadOnlyList<Envelope<IEvent>> events, string position)
{
return Task.CompletedTask;
}
return DoAndUpdateStateAsync(async () =>
{
if (eventConsumer!.Handles(storedEvent.Value))
{
var @event = ParseKnownEvent(storedEvent.Value);
if (@event != null)
{
await DispatchConsumerAsync(@event);
}
}
await DispatchAsync(events);
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(() =>
{
Unsubscribe();
State = State.Stopped(exception.Value);
State = State.Stopped(exception);
});
}
@ -119,14 +107,14 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{
await DoAndUpdateStateAsync(() =>
{
Subscribe(State.Position);
Subscribe();
State = State.Started();
});
}
else if (!State.IsStopped)
{
Subscribe(State.Position);
Subscribe();
}
}
@ -139,7 +127,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await DoAndUpdateStateAsync(() =>
{
Subscribe(State.Position);
Subscribe();
State = State.Started();
});
@ -172,21 +160,36 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await ClearAsync();
Subscribe(null);
State = State.Reset();
Subscribe();
});
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)
{
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)
{
var previousState = State;
try
{
await action();
@ -207,11 +210,14 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
.WriteProperty("status", "Failed")
.WriteProperty("eventConsumer", eventConsumer!.Name));
State = State.Stopped(ex);
State = previousState.Stopped(ex);
}
if (State != previousState)
{
await state.WriteAsync();
}
}
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()
{
var subscription = Interlocked.Exchange(ref currentSubscription, null);
var subscription = Interlocked.Exchange(ref currentSubscriber, null);
if (subscription != null)
{
subscription.StopAsync().Forget();
}
subscription?.Unsubscribe();
}
private void Subscribe(string? position)
private void Subscribe()
{
if (currentSubscription == null)
if (currentSubscriber == null)
{
currentSubscription = CreateSubscription(eventConsumer!.EventsFilter, position);
currentSubscriber = CreateSubscription();
}
else
{
currentSubscription.WakeUp();
currentSubscriber.WakeUp();
}
}
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)
{
log.LogDebug(w => w.WriteProperty("oldEventFound", storedEvent.Data.Type));
return null;
}
}
protected virtual IEventConsumerGrain GetSelf()
protected virtual TaskScheduler GetScheduler()
{
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();
}
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)

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

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Orleans.Concurrency;
using Squidex.Infrastructure.Orleans;
@ -21,9 +20,5 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
Task<Immutable<EventConsumerInfo>> StartAsync();
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.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.EventSourcing
@ -13,14 +14,35 @@ namespace Squidex.Infrastructure.EventSourcing
public interface IEventConsumer
{
int BatchDelay => 500;
int BatchSize => 1;
string Name { get; }
string EventsFilter { get; }
string EventsFilter => ".*";
bool Handles(StoredEvent @event);
bool Handles(StoredEvent @event)
{
return true;
}
Task ClearAsync();
Task ClearAsync()
{
return Task.CompletedTask;
}
Task On(Envelope<IEvent> @event);
Task On(Envelope<IEvent> @event)
{
return Task.CompletedTask;
}
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.
// ==========================================================================
using System.Threading.Tasks;
namespace Squidex.Infrastructure.EventSourcing
{
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.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Timers;
namespace Squidex.Infrastructure.EventSourcing
@ -47,9 +47,9 @@ namespace Squidex.Infrastructure.EventSourcing
timer.SkipCurrentDelay();
}
public Task StopAsync()
public void Unsubscribe()
{
return timer.StopAsync();
timer.StopAsync().Forget();
}
}
}

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

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

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

@ -8,6 +8,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
namespace Squidex.Infrastructure.Tasks
{
@ -50,5 +51,37 @@ namespace Squidex.Infrastructure.Tasks
.GetAwaiter()
.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
{
variableValue = Convert.ToString(property.GetValue(args), culture);
var value = property.GetValue(args);
if (value != null)
{
variableValue = Convert.ToString(value, culture) ?? variableName;
}
}
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">
<value>Non può avere valori duplicati.</value>
</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">
<value>Deve essere esattamente {value}.</value>
</data>

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

@ -526,6 +526,9 @@
<data name="contents.validation.duplicates" xml:space="preserve">
<value>Mag geen dubbele waarden bevatten.</value>
</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">
<value>Moet exact {waarde} zijn.</value>
</data>

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

@ -526,6 +526,9 @@
<data name="contents.validation.duplicates" xml:space="preserve">
<value>Must not contain duplicate values.</value>
</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">
<value>Must be exactly {value}.</value>
</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.Steps;
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.History;
using Squidex.Domain.Apps.Entities.Search;
@ -86,20 +86,27 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>();
services.AddSingletonAs<LuceneTextIndex>()
.As<ITextIndex>();
services.AddSingletonAs<TextIndexingProcess>()
.As<IEventConsumer>();
services.AddSingletonAs<ContentsSearchSource>()
.As<ISearchSource>();
services.AddSingletonAs<IndexManager>()
.AsSelf();
services.AddSingletonAs<GrainBootstrap<IContentSchedulerGrain>>()
.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 Migrations.Migrations.MongoDb;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
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.History.Repositories;
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)))
.As<IContentRepository>().As<ISnapshotStore<ContentState, DomainId>>();
services.AddSingletonAs<MongoTextIndex>()
.AsOptional<ITextIndex>();
services.AddSingletonAs<MongoTextIndexerState>()
.As<ITextIndexerState>();
@ -131,18 +133,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<MongoPersistedGrantStore>()
.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
},
"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.
*/

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

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
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.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
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.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Validation;
@ -25,6 +29,68 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
private readonly List<ValidationError> errors = new List<ValidationError>();
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]
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.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Core.ValidateContent.Validators;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
{
public delegate ValidationContext ValidationUpdater(ValidationContext context);
public static class ValidationTestExtensions
{
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 ISemanticLog Log = A.Fake<ISemanticLog>();
private static readonly IValidatorsFactory Factory = new DefaultValidatorsFactory();
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);
@ -33,22 +39,28 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
}
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 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));
}
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 validator = new ContentValidator(partitionResolver, context, Enumerable.Repeat(Factory, 1));
var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log);
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,
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 validator = new ContentValidator(partitionResolver, context, Enumerable.Repeat(Factory, 1));
var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log);
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(
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);
}
[Fact]
public void Should_return_assets_filter_for_events_filter()
{
IEventConsumer consumer = sut;
Assert.Equal("^assetFolder\\-", consumer.EventsFilter);
}
[Fact]
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]

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);
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.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.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Xunit;
@ -28,60 +30,100 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
[Fact]
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))
.Returns(state);
var state = new TextContentState { UniqueContentId = contentId };
var found1 = await sut.GetAsync(appId, contentId);
var found2 = await sut.GetAsync(appId, contentId);
var states = new Dictionary<DomainId, TextContentState>
{
[contentId] = state
};
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>.That.Is(contentIds)))
.Returns(states);
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>());
Assert.Same(state, found1);
Assert.Same(state, found2);
var found1 = await sut.GetAsync(HashSet.Of(contentId));
var found2 = await sut.GetAsync(HashSet.Of(contentId));
A.CallTo(() => inner.GetAsync(appId, contentId))
Assert.Empty(found1);
Assert.Empty(found2);
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>.That.Is(contentIds)))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_not_retrieve_from_inner_when_cached()
{
var state = new TextContentState { ContentId = contentId };
var contentIds = HashSet.Of(contentId);
await sut.SetAsync(appId, state);
var state = new TextContentState { UniqueContentId = contentId };
var found1 = await sut.GetAsync(appId, contentId);
var found2 = await sut.GetAsync(appId, contentId);
await sut.SetAsync(new List<TextContentState>
{
state
});
Assert.Same(state, found1);
Assert.Same(state, found2);
var found1 = await sut.GetAsync(contentIds);
var found2 = await sut.GetAsync(contentIds);
A.CallTo(() => inner.SetAsync(appId, state))
Assert.Same(state, found1[contentId]);
Assert.Same(state, found2[contentId]);
A.CallTo(() => inner.SetAsync(A<List<TextContentState>>.That.IsSameSequenceAs(state)))
.MustHaveHappenedOnceExactly();
A.CallTo(() => inner.GetAsync(appId, contentId))
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>._))
.MustNotHaveHappened();
}
[Fact]
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 found2 = await sut.GetAsync(appId, contentId);
var found1 = await sut.GetAsync(contentIds);
var found2 = await sut.GetAsync(contentIds);
Assert.Null(found1);
Assert.Null(found2);
Assert.Empty(found1);
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();
A.CallTo(() => inner.GetAsync(appId, contentId))
A.CallTo(() => inner.GetAsync(A<HashSet<DomainId>>._))
.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;
}
}
}

120
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 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();
protected TextIndexerTestsBase()
@ -46,39 +52,92 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
Language.EN);
}
[Fact]
[SkippableFact]
public async Task Should_throw_exception_for_invalid_query()
{
Skip.IfNot(SupportsSearchSyntax);
await Assert.ThrowsAsync<ValidationException>(async () =>
{
await TestCombinations(Search(expected: null, text: "~hello"));
});
}
[Fact]
public async Task Should_index_invariant_content_and_retrieve()
[SkippableFact]
public async Task Should_index_invariant_content_and_retrieve_with_fuzzy()
{
Skip.IfNot(SupportsSearchSyntax);
await TestCombinations(
Create(ids1[0], "iv", "Hello"),
Create(ids2[0], "iv", "World"),
Search(expected: ids1, text: "Hello"),
Search(expected: ids2, text: "World"),
Search(expected: ids1, text: "helo~"),
Search(expected: ids2, text: "wold~", SearchScope.All)
);
}
Search(expected: null, text: "Hello", SearchScope.Published),
Search(expected: null, text: "World", SearchScope.Published)
[SkippableFact]
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]
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(
Create(ids1[0], "iv", "Hello"),
Create(ids2[0], "iv", "World"),
Search(expected: ids1, text: "helo~"),
Search(expected: ids2, text: "wold~", SearchScope.All)
Search(expected: ids1, text: "Hello"),
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)
{
var data =
@ -375,7 +404,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
contentEvent.AppId = appId;
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)
@ -384,7 +413,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
{
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)
{
@ -398,12 +427,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
}
private async Task TestCombinations(params IndexOperation[] actions)
{
if (SupportsCleanup)
{
for (var i = 0; i < actions.Length; i++)
{
await TestCombinations(i, actions);
}
}
else
{
await TestCombinations(0, actions);
}
}
private async Task TestCombinations(int firstSteps, params IndexOperation[] 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.
// ==========================================================================
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.MongoDb.FullText;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Text
@ -12,6 +16,33 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
[Trait("Category", "Dependencies")]
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]
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]
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]
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]

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

@ -32,6 +32,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup>
<PropertyGroup>
<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!);
Assert.Same(culture.Name, sut.Culture);
Assert.Same(cultureUI.Name, sut.CultureUI);
Assert.Equal(culture.Name, sut.Culture);
Assert.Equal(cultureUI.Name, sut.CultureUI);
}
[Fact, Trait("Category", "Dependencies")]
@ -76,10 +76,10 @@ namespace Squidex.Infrastructure.Commands
var request = CommandRequest.Create(null!);
var cultureFromGrain = await grain.GetCultureAsync(request);
var cultureUIFromGrain = await grain.GetCultureAsync(request);
var cultureUIFromGrain = await grain.GetCultureUIAsync(request);
Assert.Same(culture.Name, cultureFromGrain);
Assert.Same(cultureUI.Name, cultureUIFromGrain);
Assert.Equal(culture.Name, cultureFromGrain);
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
{
if (subscription != null)
{
await subscription.StopAsync();
}
subscription?.Unsubscribe();
}
}

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

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

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

@ -27,7 +27,7 @@ namespace Squidex.Infrastructure.EventSourcing
mongoClient = new MongoClient(connectionString);
mongoDatabase = mongoClient.GetDatabase($"EventStoreTest");
Dispose();
Cleanup();
BsonJsonConvention.Register(JsonSerializer.Create(JsonHelper.DefaultSettings()));
@ -35,10 +35,15 @@ namespace Squidex.Infrastructure.EventSourcing
EventStore.InitializeAsync().Wait();
}
public void Dispose()
public void Cleanup()
{
mongoClient.DropDatabase("EventStoreTest");
}
public void Dispose()
{
Cleanup();
}
}
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)
{
}
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
@ -206,16 +191,6 @@ namespace Squidex.Infrastructure.EventSourcing
this.expectedCount = expectedCount;
}
public Task ClearAsync()
{
return Task.CompletedTask;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public async Task On(Envelope<IEvent> @event)
{
Received++;
@ -237,6 +212,7 @@ namespace Squidex.Infrastructure.EventSourcing
public MongoParallelInsertTests(MongoEventStoreReplicaSetFixture fixture)
{
_ = fixture;
_.Cleanup();
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);
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._))
.MustHaveHappened(1, Times.Exactly);
.MustHaveHappenedOnceExactly();
}
[Fact]
@ -95,7 +95,7 @@ namespace Squidex.Infrastructure.EventSourcing
{
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 IEventSubscriber sutSubscriber;
private readonly RetrySubscription sut;
private readonly string streamFilter = Guid.NewGuid().ToString();
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;
}
[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();
}
@ -46,15 +57,15 @@ namespace Squidex.Infrastructure.EventSourcing
await Task.Delay(1000);
await sut.StopAsync();
sut.Unsubscribe();
A.CallTo(() => eventSubscription.StopAsync())
A.CallTo(() => eventSubscription.Unsubscribe())
.MustHaveHappened(2, Times.Exactly);
A.CallTo(() => eventStore.CreateSubscription(A<IEventSubscriber>._, A<string>._, A<string>._))
.MustHaveHappened(2, Times.Exactly);
A.CallTo(() => eventSubscriber.OnErrorAsync(A<IEventSubscription>._, A<Exception>._))
A.CallTo(() => eventSubscriber.OnErrorAsync(eventSubscription, A<Exception>._))
.MustNotHaveHappened();
}
@ -64,26 +75,28 @@ namespace Squidex.Infrastructure.EventSourcing
var ex = new InvalidOperationException();
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(null!, ex);
await OnErrorAsync(null!, ex);
await OnErrorAsync(null!, ex);
await OnErrorAsync(null!, ex);
await OnErrorAsync(null!, ex);
await sut.StopAsync();
A.CallTo(() => eventSubscriber.OnErrorAsync(sut, ex))
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnErrorAsync(eventSubscription, ex))
.MustHaveHappened();
}
[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();
await OnErrorAsync(A.Fake<IEventSubscription>(), ex);
await sut.StopAsync();
await OnErrorAsync(eventSubscription, ex);
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnErrorAsync(A<IEventSubscription>._, A<Exception>._))
A.CallTo(() => eventSubscriber.OnErrorAsync(eventSubscription, A<Exception>._))
.MustNotHaveHappened();
}
@ -93,22 +106,24 @@ namespace Squidex.Infrastructure.EventSourcing
var ev = new StoredEvent("Stream", "1", 2, new EventData("Type", new EnvelopeHeaders(), "Payload"));
await OnEventAsync(eventSubscription, ev);
await sut.StopAsync();
A.CallTo(() => eventSubscriber.OnEventAsync(sut, ev))
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnEventAsync(eventSubscription, ev))
.MustHaveHappened();
}
[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"));
await OnEventAsync(A.Fake<IEventSubscription>(), ev);
await sut.StopAsync();
sut.Unsubscribe();
A.CallTo(() => eventSubscriber.OnEventAsync(A<IEventSubscription>._, A<StoredEvent>._))
.MustNotHaveHappened();
.MustHaveHappened();
}
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())));
A.CallTo(() => migrator_0_1.UpdateAsync())
.MustHaveHappened(1, Times.Exactly);
.MustHaveHappenedOnceExactly();
A.CallTo(() => migrator_1_2.UpdateAsync())
.MustHaveHappened(1, Times.Exactly);
.MustHaveHappenedOnceExactly();
}
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);
await cursor.ForEachPipelineAsync(x =>
await cursor.ForEachPipedAsync(x =>
{
result.Add(x);
return Task.CompletedTask;
@ -98,7 +98,7 @@ namespace Squidex.Infrastructure.MongoDb
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
{
return cursor.ForEachPipelineAsync(x =>
return cursor.ForEachPipedAsync(x =>
{
result.Add(x);
return Task.CompletedTask;
@ -120,7 +120,7 @@ namespace Squidex.Infrastructure.MongoDb
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
{
return cursor.ForEachPipelineAsync(x =>
return cursor.ForEachPipedAsync(x =>
{
if (x == 2)
{
@ -147,7 +147,7 @@ namespace Squidex.Infrastructure.MongoDb
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
{
return cursor.ForEachPipelineAsync(x =>
return cursor.ForEachPipedAsync(x =>
{
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