Browse Source

Async improvements (#1007)

* Improve async code.

* Improve assets.

* Fix tests.

* More tests

* Fix mapping.

* Fix header names.

* Fix languages header
pull/1009/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
2e95bf87fd
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs
  2. 108
      backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs
  3. 84
      backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs
  4. 6
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetHeaders.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs
  6. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs
  7. 29
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs
  8. 249
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs
  9. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ConvertTags.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichWithMetadataText.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs
  15. 82
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs
  16. 18
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentExtensions.cs
  17. 127
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs
  18. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs
  19. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
  20. 349
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs
  21. 26
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  22. 20
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  23. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  24. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  25. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs
  26. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs
  27. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
  28. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  29. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  30. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
  31. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs
  32. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs
  33. 94
      backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs
  34. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs
  35. 27
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerWorker.cs
  36. 46
      backend/src/Squidex.Domain.Apps.Entities/Tags/TagService.cs
  37. 1
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  38. 64
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  39. 77
      backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs
  40. 82
      backend/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs
  41. 1
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  42. 116
      backend/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs
  43. 109
      backend/src/Squidex.Infrastructure/Tasks/PartitionedScheduler.cs
  44. 61
      backend/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs
  45. 12
      backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeader.cs
  46. 4
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  47. 2
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  48. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetsBulkUpdateCommandMiddlewareTests.cs
  49. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs
  50. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/CalculateTokensTests.cs
  51. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ConvertTagsTests.cs
  52. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs
  53. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichWithMetadataTextTests.cs
  54. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs
  55. 67
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs
  56. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  57. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs
  58. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs
  59. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs
  60. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs
  61. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs
  62. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerWorkerTests.cs
  63. 32
      backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs
  64. 55
      backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleMapperTests.cs
  65. 23
      backend/tests/Squidex.Infrastructure.Tests/Tasks/PartitionedActionBlockTests.cs

2
backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs

@ -44,7 +44,7 @@ public sealed class ScriptActionHandler : RuleActionHandler<ScriptAction, Script
AppName = job.Event.AppId.Name, AppName = job.Event.AppId.Name,
}; };
if (job.Event is EnrichedUserEventBase userEvent) if (job.Event is EnrichedUserEventBase)
{ {
vars.User = AllPrinicpal(); vars.User = AllPrinicpal();
} }

108
backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks.Dataflow;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -28,104 +27,79 @@ public sealed class AddAppIdToEventStream : MongoBase<BsonDocument>, IMigration
public async Task UpdateAsync( public async Task UpdateAsync(
CancellationToken ct) CancellationToken ct)
{ {
const int SizeOfBatch = 1000;
const int SizeOfQueue = 20;
// Do not resolve in constructor, because most of the time it is not executed anyway. // Do not resolve in constructor, because most of the time it is not executed anyway.
var collectionV1 = database.GetCollection<BsonDocument>("Events"); var collectionV1 = database.GetCollection<BsonDocument>("Events");
var collectionV2 = database.GetCollection<BsonDocument>("Events2"); var collectionV2 = database.GetCollection<BsonDocument>("Events2");
var batchBlock = new BatchBlock<BsonDocument>(SizeOfBatch, new GroupingDataflowBlockOptions // Run batch first, because it is cheaper as it has less items.
var batchedCommits = collectionV1.Find(FindAll).ToAsyncEnumerable(ct).Batch(500, ct).Buffered(2, ct);
var options = new ParallelOptions
{ {
BoundedCapacity = SizeOfQueue * SizeOfBatch CancellationToken = ct,
}); // The tasks are mostly executed on database level, therefore we increase parallelism.
MaxDegreeOfParallelism = Environment.ProcessorCount * 2,
};
var actionBlock = new ActionBlock<BsonDocument[]>(async batch => await Parallel.ForEachAsync(batchedCommits, ct, async (batch, ct) =>
{ {
try var writes = new List<WriteModel<BsonDocument>>();
foreach (var document in batch)
{ {
var writes = new List<WriteModel<BsonDocument>>(); var eventStream = document["EventStream"].AsString;
foreach (var document in batch) if (TryGetAppId(document, out var appId))
{ {
var eventStream = document["EventStream"].AsString; if (!eventStream.StartsWith("app-", StringComparison.OrdinalIgnoreCase))
if (TryGetAppId(document, out var appId))
{ {
if (!eventStream.StartsWith("app-", StringComparison.OrdinalIgnoreCase)) var indexOfType = eventStream.IndexOf('-', StringComparison.Ordinal);
{ var indexOfId = indexOfType + 1;
var indexOfType = eventStream.IndexOf('-', StringComparison.Ordinal);
var indexOfId = indexOfType + 1;
var indexOfOldId = eventStream.LastIndexOf("--", StringComparison.OrdinalIgnoreCase); var indexOfOldId = eventStream.LastIndexOf("--", StringComparison.OrdinalIgnoreCase);
if (indexOfOldId > 0) if (indexOfOldId > 0)
{ {
indexOfId = indexOfOldId + 2; indexOfId = indexOfOldId + 2;
} }
var domainType = eventStream[..indexOfType];
var domainId = eventStream[indexOfId..];
var newDomainId = DomainId.Combine(DomainId.Create(appId), DomainId.Create(domainId)).ToString(); var domainType = eventStream[..indexOfType];
var newStreamName = $"{domainType}-{newDomainId}"; var domainId = eventStream[indexOfId..];
document["EventStream"] = newStreamName; var newDomainId = DomainId.Combine(DomainId.Create(appId), DomainId.Create(domainId)).ToString();
var newStreamName = $"{domainType}-{newDomainId}";
foreach (var @event in document["Events"].AsBsonArray) document["EventStream"] = newStreamName;
{
var metadata = @event["Metadata"].AsBsonDocument;
metadata["AggregateId"] = newDomainId;
}
}
foreach (var @event in document["Events"].AsBsonArray) foreach (var @event in document["Events"].AsBsonArray)
{ {
var metadata = @event["Metadata"].AsBsonDocument; var metadata = @event["Metadata"].AsBsonDocument;
metadata.Remove("AppId"); metadata["AggregateId"] = newDomainId;
} }
} }
var filter = Builders<BsonDocument>.Filter.Eq("_id", document["_id"].AsString); foreach (var @event in document["Events"].AsBsonArray)
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
{ {
IsUpsert = true var metadata = @event["Metadata"].AsBsonDocument;
});
metadata.Remove("AppId");
}
} }
if (writes.Count > 0) var filter = Builders<BsonDocument>.Filter.Eq("_id", document["_id"].AsString);
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
{ {
await collectionV2.BulkWriteAsync(writes, BulkUnordered, ct); IsUpsert = true
} });
} }
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount * 2,
MaxMessagesPerTask = 10,
BoundedCapacity = SizeOfQueue
});
batchBlock.BidirectionalLinkTo(actionBlock);
await foreach (var commit in collectionV1.Find(FindAll).ToAsyncEnumerable(ct: ct)) if (writes.Count > 0)
{
if (!await batchBlock.SendAsync(commit, ct))
{ {
break; await collectionV2.BulkWriteAsync(writes, BulkUnordered, ct);
} }
} });
batchBlock.Complete();
await actionBlock.Completion;
} }
private static bool TryGetAppId(BsonDocument document, [MaybeNullWhen(false)] out string appId) private static bool TryGetAppId(BsonDocument document, [MaybeNullWhen(false)] out string appId)

84
backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks.Dataflow;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -72,9 +71,6 @@ public sealed class ConvertDocumentIds : MongoBase<BsonDocument>, IMigration
private static async Task RebuildAsync(IMongoDatabase database, Action<BsonDocument>? extraAction, string collectionNameV1, private static async Task RebuildAsync(IMongoDatabase database, Action<BsonDocument>? extraAction, string collectionNameV1,
CancellationToken ct) CancellationToken ct)
{ {
const int SizeOfBatch = 1000;
const int SizeOfQueue = 10;
string collectionNameV2; string collectionNameV2;
collectionNameV2 = $"{collectionNameV1}2"; collectionNameV2 = $"{collectionNameV1}2";
@ -91,80 +87,46 @@ public sealed class ConvertDocumentIds : MongoBase<BsonDocument>, IMigration
await collectionV2.DeleteManyAsync(FindAll, ct); await collectionV2.DeleteManyAsync(FindAll, ct);
var batchBlock = new BatchBlock<BsonDocument>(SizeOfBatch, new GroupingDataflowBlockOptions // Run batch first, because it is cheaper as it has less items.
{ var batches = collectionV1.Find(FindAll).ToAsyncEnumerable(ct).Batch(500, ct).Buffered(2, ct);
BoundedCapacity = SizeOfQueue * SizeOfBatch
});
var writeOptions = new BulkWriteOptions await Parallel.ForEachAsync(batches, ct, async (batch, ct) =>
{ {
IsOrdered = false var writes = new List<WriteModel<BsonDocument>>();
};
var actionBlock = new ActionBlock<BsonDocument[]>(async batch => foreach (var document in batch)
{
try
{ {
var writes = new List<WriteModel<BsonDocument>>(); var appId = document["_ai"].AsString;
foreach (var document in batch)
{
var appId = document["_ai"].AsString;
var documentIdOld = document["_id"].AsString; var documentIdOld = document["_id"].AsString;
if (documentIdOld.Contains("--", StringComparison.OrdinalIgnoreCase)) if (documentIdOld.Contains("--", StringComparison.OrdinalIgnoreCase))
{ {
var index = documentIdOld.LastIndexOf("--", StringComparison.OrdinalIgnoreCase); var index = documentIdOld.LastIndexOf("--", StringComparison.OrdinalIgnoreCase);
documentIdOld = documentIdOld[(index + 2)..]; documentIdOld = documentIdOld[(index + 2)..];
} }
var documentIdNew = DomainId.Combine(DomainId.Create(appId), DomainId.Create(documentIdOld)).ToString(); var documentIdNew = DomainId.Combine(DomainId.Create(appId), DomainId.Create(documentIdOld)).ToString();
document["id"] = documentIdOld; document["id"] = documentIdOld;
document["_id"] = documentIdNew; document["_id"] = documentIdNew;
extraAction?.Invoke(document); extraAction?.Invoke(document);
var filter = Filter.Eq("_id", documentIdNew); var filter = Filter.Eq("_id", documentIdNew);
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document) writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
{
IsUpsert = true
});
}
if (writes.Count > 0)
{ {
await collectionV2.BulkWriteAsync(writes, writeOptions, ct); IsUpsert = true
} });
} }
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount * 2,
MaxMessagesPerTask = 1,
BoundedCapacity = SizeOfQueue
});
batchBlock.BidirectionalLinkTo(actionBlock);
await foreach (var document in collectionV1.Find(FindAll).ToAsyncEnumerable(ct: ct)) if (writes.Count > 0)
{
if (!await batchBlock.SendAsync(document, ct))
{ {
break; await collectionV2.BulkWriteAsync(writes, BulkUnordered, ct);
} }
} });
batchBlock.Complete();
await actionBlock.Completion;
} }
private static void ConvertParentId(BsonDocument document) private static void ConvertParentId(BsonDocument document)

6
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetHeaders.cs

@ -11,12 +11,12 @@ public static class AssetHeaders
{ {
public const string NoEnrichment = "X-NoAssetEnrichment"; public const string NoEnrichment = "X-NoAssetEnrichment";
public static bool ShouldSkipAssetEnrichment(this Context context) public static bool NoAssetEnrichment(this Context context)
{ {
return context.Headers.ContainsKey(NoEnrichment); return context.AsBoolean(NoEnrichment);
} }
public static ICloneBuilder WithoutAssetEnrichment(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithNoAssetEnrichment(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(NoEnrichment, value); return builder.WithBoolean(NoEnrichment, value);
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs

@ -154,7 +154,7 @@ public sealed class AssetsFluidExtension : IFluidExtension
var requestContext = var requestContext =
Context.Admin(app).Clone(b => b Context.Admin(app).Clone(b => b
.WithoutTotal()); .WithNoTotal());
var asset = await assetQuery.FindAsync(requestContext, domainId); var asset = await assetQuery.FindAsync(requestContext, domainId);

4
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs

@ -229,7 +229,7 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
var requestContext = var requestContext =
new Context(user, app).Clone(b => b new Context(user, app).Clone(b => b
.WithoutTotal()); .WithNoTotal());
var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct); var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct);
@ -264,7 +264,7 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
var requestContext = var requestContext =
new Context(user, app).Clone(b => b new Context(user, app).Clone(b => b
.WithoutTotal()); .WithNoTotal());
var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct); var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct);

29
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs

@ -128,25 +128,30 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware<Asset
{ {
var payload = await base.EnrichResultAsync(context, result, ct); var payload = await base.EnrichResultAsync(context, result, ct);
if (payload is IAssetEntity asset) if (payload is not IAssetEntity asset)
{ {
if (result.IsChanged && context.Command is UploadAssetCommand) return payload;
}
if (result.IsChanged && context.Command is UploadAssetCommand)
{
var tempFile = context.ContextId.ToString();
try
{ {
var tempFile = context.ContextId.ToString(); await assetFileStore.CopyAsync(tempFile, asset.AppId.Id, asset.AssetId, asset.FileVersion, null, ct);
try }
{ catch (AssetAlreadyExistsException)
await assetFileStore.CopyAsync(tempFile, asset.AppId.Id, asset.AssetId, asset.FileVersion, null, ct); {
} if (context.Command is not UpsertAsset)
catch (AssetAlreadyExistsException) when (context.Command is not UpsertAsset)
{ {
throw; throw;
} }
} }
}
if (payload is not IEnrichedAssetEntity) if (payload is not IEnrichedAssetEntity)
{ {
payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, ct); payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, ct);
}
} }
return payload; return payload;

249
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs

@ -5,15 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using System.Threading.Tasks.Dataflow;
using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared; using Squidex.Shared;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter #pragma warning disable SA1313 // Parameter names should begin with lower-case letter
@ -24,201 +20,140 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject;
public sealed class AssetsBulkUpdateCommandMiddleware : ICommandMiddleware public sealed class AssetsBulkUpdateCommandMiddleware : ICommandMiddleware
{ {
private readonly IContextProvider contextProvider; private readonly IContextProvider contextProvider;
private readonly ILogger<AssetsBulkUpdateCommandMiddleware> log;
private sealed record BulkTaskCommand(BulkTask Task, DomainId Id, ICommand Command,
CancellationToken CancellationToken);
private sealed record BulkTask( private sealed record BulkTask(
ICommandBus Bus, BulkUpdateJob BulkJob,
BulkUpdateAssets Bulk,
int JobIndex, int JobIndex,
BulkUpdateJob CommandJob, AssetCommand? Command)
BulkUpdateAssets Command, {
ConcurrentBag<BulkUpdateResultItem> Results, public BulkUpdateResultItem? Result { get; private set; }
CancellationToken Aborted);
public BulkTask SetResult(Exception? exception = null)
{
var id = Command?.AssetId ?? BulkJob.Id;
public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider, ILogger<AssetsBulkUpdateCommandMiddleware> log) Result = new BulkUpdateResultItem(id, JobIndex, exception);
return this;
}
public static BulkTask Failed(BulkUpdateJob bulkJob, BulkUpdateAssets bulk, int jobIndex, Exception exception)
{
return new BulkTask(bulkJob, bulk, jobIndex, null).SetResult(exception);
}
}
public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider)
{ {
this.contextProvider = contextProvider; this.contextProvider = contextProvider;
this.log = log;
} }
public async Task HandleAsync(CommandContext context, NextDelegate next, public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct) CancellationToken ct)
{ {
if (context.Command is BulkUpdateAssets bulkUpdates) if (context.Command is not BulkUpdateAssets bulkUpdates)
{
if (bulkUpdates.Jobs?.Length > 0)
{
var executionOptions = new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2)
};
// Each job can create exactly one command.
var createCommandsBlock = new TransformBlock<BulkTask, BulkTaskCommand?>(task =>
{
try
{
return CreateCommand(task);
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, executionOptions);
// Execute the commands in batches
var executeCommandBlock = new ActionBlock<BulkTaskCommand?>(async command =>
{
try
{
if (command != null)
{
await ExecuteCommandAsync(command);
}
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, executionOptions);
createCommandsBlock.BidirectionalLinkTo(executeCommandBlock);
contextProvider.Context.Change(b => b
.WithoutAssetEnrichment()
.WithoutCleanup()
.WithUnpublished(true)
.WithoutTotal());
var results = new ConcurrentBag<BulkUpdateResultItem>();
for (var i = 0; i < bulkUpdates.Jobs.Length; i++)
{
var task = new BulkTask(
context.CommandBus,
i,
bulkUpdates.Jobs[i],
bulkUpdates,
results,
ct);
if (!await createCommandsBlock.SendAsync(task, ct))
{
break;
}
}
createCommandsBlock.Complete();
// Wait for all commands to be executed.
await executeCommandBlock.Completion;
context.Complete(new BulkUpdateResult(results));
}
else
{
context.Complete(new BulkUpdateResult());
}
}
else
{ {
await next(context, ct); await next(context, ct);
return;
} }
}
private async Task ExecuteCommandAsync(BulkTaskCommand bulkCommand) if (bulkUpdates.Jobs == null || bulkUpdates.Jobs.Length == 0)
{
var (task, id, command, ct) = bulkCommand;
try
{ {
await task.Bus.PublishAsync(command, ct); context.Complete(new BulkUpdateResult());
return;
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex));
} }
catch (Exception ex)
contextProvider.Context.Change(b => b
.WithNoAssetEnrichment()
.WithNoCleanup()
.WithUnpublished(true)
.WithNoTotal());
var tasks = bulkUpdates.Jobs.Select((job, i) => CreateTask(job, bulkUpdates, i)).ToList();
// Group the items by id, so that we do not run jobs in parallel on the same entity.
var groupedTasks = tasks.GroupBy(x => x.BulkJob.Id).ToList();
await Parallel.ForEachAsync(groupedTasks, ct, async (group, ct) =>
{ {
log.LogError(ex, "Failed to execute asset bulk job with index {index} of type {type}.", foreach (var task in group)
task.JobIndex, {
task.CommandJob.Type); await ExecuteCommandAsync(context.CommandBus, task, ct);
}
});
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex, ex)); context.Complete(new BulkUpdateResult(tasks.Select(x => x.Result).NotNull()));
}
} }
private BulkTaskCommand? CreateCommand(BulkTask task) private static async Task ExecuteCommandAsync(ICommandBus commandBus, BulkTask task,
CancellationToken ct)
{ {
var id = task.CommandJob.Id; if (task.Result != null || task.Command == null)
try
{ {
var command = CreateCommandCore(task); return;
}
// Set the asset id here in case we have another way to resolve ids.
command.AssetId = id;
return new BulkTaskCommand(task, id, command, task.Aborted); try
{
await commandBus.PublishAsync(task.Command, ct);
task.SetResult();
} }
catch (Exception ex) catch (Exception ex)
{ {
log.LogError(ex, "Failed to execute asset bulk job with index {index} of type {type}.", task.SetResult(ex);
task.JobIndex,
task.CommandJob.Type);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex, ex));
return null;
} }
} }
private AssetCommand CreateCommandCore(BulkTask task) private BulkTask CreateTask(
BulkUpdateJob bulkJob,
BulkUpdateAssets bulk,
int jobIndex)
{ {
var job = task.CommandJob; try
switch (job.Type)
{ {
case BulkUpdateAssetType.Annotate: switch (bulkJob.Type)
{ {
var command = new AnnotateAsset(); case BulkUpdateAssetType.Annotate:
return CreateTask<AnnotateAsset>(bulkJob, bulk, jobIndex,
EnrichAndCheckPermission(task, command, PermissionIds.AppAssetsUpdate); PermissionIds.AppAssetsUpdate);
return command;
}
case BulkUpdateAssetType.Move:
{
var command = new MoveAsset();
EnrichAndCheckPermission(task, command, PermissionIds.AppAssetsUpdate);
return command;
}
case BulkUpdateAssetType.Delete: case BulkUpdateAssetType.Move:
{ return CreateTask<MoveAsset>(bulkJob, bulk, jobIndex,
var command = new DeleteAsset(); PermissionIds.AppAssetsUpdate);
EnrichAndCheckPermission(task, command, PermissionIds.AppAssetsDelete); case BulkUpdateAssetType.Delete:
return command; return CreateTask<DeleteAsset>(bulkJob, bulk, jobIndex,
} PermissionIds.AppAssetsDelete);
default: default:
ThrowHelper.NotSupportedException(); return BulkTask.Failed(bulkJob, bulk, jobIndex, new NotSupportedException());
return default!; }
}
catch (Exception ex)
{
return BulkTask.Failed(bulkJob, bulk, jobIndex, ex);
} }
} }
private void EnrichAndCheckPermission<T>(BulkTask task, T command, string permissionId) where T : AssetCommand private BulkTask CreateTask<T>(
BulkUpdateJob bulkJob,
BulkUpdateAssets bulk,
int jobIndex,
string permissionId) where T : AssetCommand, new()
{ {
SimpleMapper.Map(task.Command, command);
SimpleMapper.Map(task.CommandJob, command);
if (!contextProvider.Context.Allows(permissionId)) if (!contextProvider.Context.Allows(permissionId))
{ {
throw new DomainForbiddenException("Forbidden"); return BulkTask.Failed(bulkJob, bulk, jobIndex, new DomainForbiddenException("Forbidden"));
} }
command.ExpectedVersion = task.Command.ExpectedVersion; var command = new T();
SimpleMapper.Map(bulk, command);
SimpleMapper.Map(bulkJob, command);
command.ExpectedVersion = bulk.ExpectedVersion;
command.AssetId = bulkJob.Id;
return new BulkTask(bulkJob, bulk, jobIndex, command);
} }
} }

4
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs

@ -54,11 +54,11 @@ public class AssetQueryParser
q = q.WithQuery(query); q = q.WithQuery(query);
if (context.ShouldSkipTotal()) if (context.NoTotal())
{ {
q = q.WithoutTotal(); q = q.WithoutTotal();
} }
else if (context.ShouldSkipSlowTotal()) else if (context.NoSlowTotal())
{ {
q = q.WithoutSlowTotal(); q = q.WithoutSlowTotal();
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs

@ -25,7 +25,7 @@ public sealed class CalculateTokens : IAssetEnricherStep
public Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets, public Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets,
CancellationToken ct) CancellationToken ct)
{ {
if (context.ShouldSkipAssetEnrichment()) if (context.NoAssetEnrichment())
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ConvertTags.cs

@ -22,7 +22,7 @@ public sealed class ConvertTags : IAssetEnricherStep
public async Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets, public async Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets,
CancellationToken ct) CancellationToken ct)
{ {
if (context.ShouldSkipAssetEnrichment()) if (context.NoAssetEnrichment())
{ {
return; return;
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs

@ -54,6 +54,6 @@ public sealed class EnrichForCaching : IAssetEnricherStep
private static bool ShouldEnrich(Context context) private static bool ShouldEnrich(Context context)
{ {
return !context.ShouldSkipCacheKeys(); return !context.NoCacheKeys();
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichWithMetadataText.cs

@ -22,7 +22,7 @@ public sealed class EnrichWithMetadataText : IAssetEnricherStep
public Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets, public Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets,
CancellationToken ct) CancellationToken ct)
{ {
if (context.ShouldSkipAssetEnrichment()) if (context.NoAssetEnrichment())
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs

@ -100,7 +100,7 @@ public sealed class ScriptAsset : IAssetEnricherStep
{ {
// We need a special permission to disable scripting for security reasons, if the script removes sensible data. // We need a special permission to disable scripting for security reasons, if the script removes sensible data.
var shouldScript = var shouldScript =
!context.ShouldSkipScripting() || !context.NoScripting() ||
!context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name)); !context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name));
return !context.IsFrontendClient && shouldScript; return !context.IsFrontendClient && shouldScript;

82
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks.Dataflow; using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
@ -285,67 +285,55 @@ public sealed partial class RestoreProcessor
private async Task ReadEventsAsync(Run run, private async Task ReadEventsAsync(Run run,
CancellationToken ct) CancellationToken ct)
{ {
const int BatchSize = 100; // Run batch first, because it is cheaper as it has less items.
var events = HandleEventsAsync(run, ct).Batch(100, ct).Buffered(2, ct);
var handled = 0; var handled = 0;
var writeBlock = new ActionBlock<(string, Envelope<IEvent>)[]>(async batch => await Parallel.ForEachAsync(events, new ParallelOptions
{ {
try CancellationToken = ct,
{ // The event store cannot insert events in parallel.
var commits = new List<EventCommit>(batch.Length); MaxDegreeOfParallelism = 1,
},
foreach (var (stream, @event) in batch) async (batch, ct) =>
{ {
var offset = run.StreamMapper.GetStreamOffset(stream); var commits =
batch.Select(item =>
commits.Add(EventCommit.Create(stream, offset, @event, eventFormatter)); EventCommit.Create(
} item.Stream,
item.Offset,
item.Event,
eventFormatter));
await eventStore.AppendUnsafeAsync(commits, ct); await eventStore.AppendUnsafeAsync(commits, ct);
handled += commits.Count; // Just in case we use parallel inserts later.
Interlocked.Increment(ref handled);
await LogAsync(run, $"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true); await LogAsync(run, $"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true);
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = 1,
BoundedCapacity = 2
});
var batchBlock = new BatchBlock<(string, Envelope<IEvent>)>(BatchSize, new GroupingDataflowBlockOptions
{
BoundedCapacity = BatchSize * 2
}); });
}
batchBlock.BidirectionalLinkTo(writeBlock); private async IAsyncEnumerable<(string Stream, long Offset, Envelope<IEvent> Event)> HandleEventsAsync(Run run,
[EnumeratorCancellation] CancellationToken ct)
{
var @events = run.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct);
await foreach (var job in run.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct)) await foreach (var (stream, @event) in events.WithCancellation(ct))
{ {
var newStream = await HandleEventAsync(run, job.Stream, job.Event, ct); var (newStream, handled) = await HandleEventAsync(run, stream, @event, ct);
if (newStream != null) if (handled)
{ {
if (!await batchBlock.SendAsync((newStream, job.Event), ct)) var offset = run.StreamMapper.GetStreamOffset(newStream);
{
break; yield return (newStream, offset, @event);
}
} }
} }
batchBlock.Complete();
await writeBlock.Completion;
} }
private async Task<string?> HandleEventAsync(Run run, string stream, Envelope<IEvent> @event, private async Task<(string StreamName, bool Handled)> HandleEventAsync(Run run, string stream, Envelope<IEvent> @event,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (@event.Payload is AppCreated appCreated) if (@event.Payload is AppCreated appCreated)
@ -390,11 +378,11 @@ public sealed partial class RestoreProcessor
{ {
if (!await handler.RestoreEventAsync(@event, run.Context, ct)) if (!await handler.RestoreEventAsync(@event, run.Context, ct))
{ {
return null; return (newStream, false);
} }
} }
return newStream; return (newStream, true);
} }
private async Task CreateContextAsync(Run run, DomainId previousAppId, private async Task CreateContextAsync(Run run, DomainId previousAppId,

18
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentExtensions.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents;
public static class ContentExtensions
{
public static Status EditingStatus(this IContentEntity content)
{
return content.NewStatus ?? content.Status;
}
}

127
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs

@ -8,6 +8,7 @@
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Caching;
using static OpenIddict.Abstractions.OpenIddictConstants;
#pragma warning disable IDE0060 // Remove unused parameter #pragma warning disable IDE0060 // Remove unused parameter
@ -15,34 +16,32 @@ namespace Squidex.Domain.Apps.Entities.Contents;
public static class ContentHeaders public static class ContentHeaders
{ {
private static readonly char[] Separators = { ',', ';' }; public const string KeyFields = "X-Fields";
public const string KeyFlatten = "X-Flatten";
public const string Fields = "X-Fields"; public const string KeyLanguages = "X-Languages";
public const string Flatten = "X-Flatten"; public const string KeyNoCleanup = "X-NoCleanup";
public const string Languages = "X-Languages"; public const string KeyNoEnrichment = "X-NoEnrichment";
public const string NoCleanup = "X-NoCleanup"; public const string KeyNoResolveLanguages = "X-NoResolveLanguages";
public const string NoEnrichment = "X-NoEnrichment"; public const string KeyResolveFlow = "X-ResolveFlow";
public const string NoResolveLanguages = "X-NoResolveLanguages"; public const string KeyResolveUrls = "X-ResolveUrls";
public const string ResolveFlow = "X-ResolveFlow"; public const string KeyUnpublished = "X-Unpublished";
public const string ResolveUrls = "X-ResolveUrls";
public const string Unpublished = "X-Unpublished";
public static void AddCacheHeaders(this Context context, IRequestCache cache) public static void AddCacheHeaders(this Context context, IRequestCache cache)
{ {
cache.AddHeader(Fields); cache.AddHeader(KeyFields);
cache.AddHeader(Flatten); cache.AddHeader(KeyFlatten);
cache.AddHeader(Languages); cache.AddHeader(KeyLanguages);
cache.AddHeader(NoCleanup); cache.AddHeader(KeyNoCleanup);
cache.AddHeader(NoEnrichment); cache.AddHeader(KeyNoEnrichment);
cache.AddHeader(NoResolveLanguages); cache.AddHeader(KeyNoResolveLanguages);
cache.AddHeader(ResolveFlow); cache.AddHeader(KeyResolveFlow);
cache.AddHeader(ResolveUrls); cache.AddHeader(KeyResolveUrls);
cache.AddHeader(Unpublished); cache.AddHeader(KeyUnpublished);
} }
public static Status EditingStatus(this IContentEntity content) public static SearchScope Scope(this Context context)
{ {
return content.NewStatus ?? content.Status; return context.Unpublished() || context.IsFrontendClient ? SearchScope.All : SearchScope.Published;
} }
public static bool IsPublished(this IContentEntity content) public static bool IsPublished(this IContentEntity content)
@ -50,113 +49,93 @@ public static class ContentHeaders
return content.EditingStatus() == Status.Published; return content.EditingStatus() == Status.Published;
} }
public static SearchScope Scope(this Context context) public static bool NoCleanup(this Context context)
{
return context.ShouldProvideUnpublished() || context.IsFrontendClient ? SearchScope.All : SearchScope.Published;
}
public static bool ShouldSkipCleanup(this Context context)
{ {
return context.Headers.ContainsKey(NoCleanup); return context.AsBoolean(KeyNoCleanup);
} }
public static ICloneBuilder WithoutCleanup(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithNoCleanup(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(NoCleanup, value); return builder.WithBoolean(KeyNoCleanup, value);
} }
public static bool ShouldSkipContentEnrichment(this Context context) public static bool NoEnrichment(this Context context)
{ {
return context.Headers.ContainsKey(NoEnrichment); return context.AsBoolean(KeyNoEnrichment);
} }
public static ICloneBuilder WithoutContentEnrichment(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithNoEnrichment(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(NoEnrichment, value); return builder.WithBoolean(KeyNoEnrichment, value);
} }
public static bool ShouldProvideUnpublished(this Context context) public static bool Unpublished(this Context context)
{ {
return context.Headers.ContainsKey(Unpublished); return context.AsBoolean(KeyUnpublished);
} }
public static ICloneBuilder WithUnpublished(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithUnpublished(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(Unpublished, value); return builder.WithBoolean(KeyUnpublished, value);
} }
public static bool ShouldFlatten(this Context context) public static bool Flatten(this Context context)
{ {
return context.Headers.ContainsKey(Flatten); return context.AsBoolean(KeyFlatten);
} }
public static ICloneBuilder WithFlatten(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithFlatten(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(Flatten, value); return builder.WithBoolean(KeyFlatten, value);
} }
public static bool ShouldResolveFlow(this Context context) public static bool ResolveFlow(this Context context)
{ {
return context.Headers.ContainsKey(ResolveFlow); return context.AsBoolean(KeyResolveFlow);
} }
public static ICloneBuilder WithResolveFlow(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithResolveFlow(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(ResolveFlow, value); return builder.WithBoolean(KeyResolveFlow, value);
} }
public static bool ShouldResolveLanguages(this Context context) public static bool NoResolveLanguages(this Context context)
{ {
return !context.Headers.ContainsKey(NoResolveLanguages); return context.AsBoolean(KeyNoResolveLanguages);
} }
public static ICloneBuilder WithoutResolveLanguages(this ICloneBuilder builder, bool value = true) public static ICloneBuilder WithNoResolveLanguages(this ICloneBuilder builder, bool value = true)
{ {
return builder.WithBoolean(NoResolveLanguages, value); return builder.WithBoolean(KeyNoResolveLanguages, value);
} }
public static IEnumerable<string> AssetUrls(this Context context) public static IEnumerable<string> ResolveUrls(this Context context)
{ {
if (context.Headers.TryGetValue(ResolveUrls, out var value)) return context.AsStrings(KeyResolveUrls);
{
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToHashSet();
}
return Enumerable.Empty<string>();
} }
public static ICloneBuilder WithAssetUrlsToResolve(this ICloneBuilder builder, IEnumerable<string>? fieldNames) public static ICloneBuilder WithResolveUrls(this ICloneBuilder builder, IEnumerable<string>? fieldNames)
{ {
return builder.WithStrings(ResolveUrls, fieldNames); return builder.WithStrings(KeyResolveUrls, fieldNames);
} }
public static HashSet<string>? FieldsList(this Context context) public static HashSet<string>? Fields(this Context context)
{ {
if (context.Headers.TryGetValue(Fields, out var value)) return context.AsStrings(KeyFields).ToHashSet();
{
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).ToHashSet();
}
return null;
} }
public static ICloneBuilder WithFields(this ICloneBuilder builder, IEnumerable<string> fields) public static ICloneBuilder WithFields(this ICloneBuilder builder, IEnumerable<string>? fields)
{ {
return builder.WithStrings(Fields, fields); return builder.WithStrings(KeyFields, fields);
} }
public static HashSet<Language> LanguagesList(this Context context) public static HashSet<Language> Languages(this Context context)
{ {
if (context.Headers.TryGetValue(Languages, out var value)) return context.AsStrings(KeyLanguages).Select(Language.GetLanguage).ToHashSet();
{
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => (Language)x).ToHashSet();
}
return new HashSet<Language>();
} }
public static ICloneBuilder WithLanguages(this ICloneBuilder builder, IEnumerable<string> languages) public static ICloneBuilder WithLanguages(this ICloneBuilder builder, IEnumerable<string> languages)
{ {
return builder.WithStrings(Languages, languages); return builder.WithStrings(KeyLanguages, languages);
} }
} }

1
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs

@ -62,6 +62,7 @@ public sealed class ContentsSearchSource : ISearchSource
return result; return result;
} }
// Resolve the app ID once, because we loop over the results.
var appId = context.App.NamedId(); var appId = context.App.NamedId();
var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).WithoutTotal(), ct); var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).WithoutTotal(), ct);

6
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs

@ -439,7 +439,11 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
private void Create(CreateContent command, Status status) private void Create(CreateContent command, Status status)
{ {
Raise(command, new ContentCreated { Status = status }); var @event = SimpleMapper.Map(command, new ContentCreated());
@event.Status = status;
RaiseEvent(Envelope.Create(@event));
} }
private void Update(ContentCommand command, ContentData data) private void Update(ContentCommand command, ContentData data)

349
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs

@ -5,16 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using System.Threading.Tasks.Dataflow;
using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Shared; using Squidex.Shared;
@ -27,299 +22,239 @@ public sealed class ContentsBulkUpdateCommandMiddleware : ICommandMiddleware
{ {
private readonly IContentQueryService contentQuery; private readonly IContentQueryService contentQuery;
private readonly IContextProvider contextProvider; private readonly IContextProvider contextProvider;
private readonly ILogger<ContentsBulkUpdateCommandMiddleware> log;
private sealed record BulkTaskCommand(BulkTask Task, DomainId Id, ICommand Command,
CancellationToken CancellationToken);
private sealed record BulkTask( private sealed record BulkTask(
ICommandBus Bus, BulkUpdateJob BulkJob,
NamedId<DomainId> SchemaId, BulkUpdateContents Bulk,
int JobIndex, int JobIndex,
BulkUpdateJob CommandJob, ContentCommand? Command)
BulkUpdateContents Command, {
ConcurrentBag<BulkUpdateResultItem> Results, public BulkUpdateResultItem? Result { get; private set; }
CancellationToken Aborted);
public BulkTask SetResult(Exception? exception = null)
{
var id = Command?.ContentId ?? BulkJob.Id;
Result = new BulkUpdateResultItem(id, JobIndex, exception);
return this;
}
public static BulkTask Failed(BulkUpdateJob bulkJob, BulkUpdateContents bulk, int jobIndex, Exception exception)
{
return new BulkTask(bulkJob, bulk, jobIndex, null).SetResult(exception);
}
}
public ContentsBulkUpdateCommandMiddleware( public ContentsBulkUpdateCommandMiddleware(
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContextProvider contextProvider, IContextProvider contextProvider)
ILogger<ContentsBulkUpdateCommandMiddleware> log)
{ {
this.contentQuery = contentQuery; this.contentQuery = contentQuery;
this.contextProvider = contextProvider; this.contextProvider = contextProvider;
this.log = log;
} }
public async Task HandleAsync(CommandContext context, NextDelegate next, public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct) CancellationToken ct)
{ {
if (context.Command is BulkUpdateContents bulkUpdates) if (context.Command is not BulkUpdateContents bulkUpdates)
{ {
if (bulkUpdates.Jobs?.Length > 0) await next(context, ct);
{ return;
var executionOptions = new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2)
};
// Each job can create one or more commands.
var createCommandsBlock = new TransformManyBlock<BulkTask, BulkTaskCommand>(async task =>
{
try
{
return await CreateCommandsAsync(task);
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, executionOptions);
// Execute the commands in batches.
var executeCommandBlock = new ActionBlock<BulkTaskCommand>(async command =>
{
try
{
await ExecuteCommandAsync(command);
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, executionOptions);
createCommandsBlock.BidirectionalLinkTo(executeCommandBlock);
contextProvider.Context.Change(b => b
.WithoutContentEnrichment()
.WithoutCleanup()
.WithUnpublished(true)
.WithoutTotal());
var results = new ConcurrentBag<BulkUpdateResultItem>();
for (var i = 0; i < bulkUpdates.Jobs.Length; i++)
{
var task = new BulkTask(
context.CommandBus,
bulkUpdates.SchemaId,
i,
bulkUpdates.Jobs[i],
bulkUpdates,
results,
ct);
if (!await createCommandsBlock.SendAsync(task, ct))
{
break;
}
}
createCommandsBlock.Complete();
// Wait for all commands to be executed.
await executeCommandBlock.Completion;
context.Complete(new BulkUpdateResult(results));
}
else
{
context.Complete(new BulkUpdateResult());
}
} }
else
if (bulkUpdates.Jobs == null || bulkUpdates.Jobs.Length == 0)
{ {
await next(context, ct); context.Complete(new BulkUpdateResult());
return;
} }
contextProvider.Context.Change(b => b
.WithNoEnrichment()
.WithNoCleanup()
.WithUnpublished(true)
.WithNoTotal());
var tasks = await bulkUpdates.Jobs.SelectManyAsync((job, i, ct) => CreateTasksAsync(job, bulkUpdates, i, ct), ct);
// Group the items by id, so that we do not run jobs in parallel on the same entity.
var groupedTasks = tasks.GroupBy(x => x.Command?.ContentId).ToList();
await Parallel.ForEachAsync(groupedTasks, ct, async (group, ct) =>
{
foreach (var task in group)
{
await ExecuteCommandAsync(context.CommandBus, task, ct);
}
});
context.Complete(new BulkUpdateResult(tasks.Select(x => x.Result).NotNull()));
} }
private async Task ExecuteCommandAsync(BulkTaskCommand bulkCommand) private static async Task ExecuteCommandAsync(ICommandBus commandBus, BulkTask task,
CancellationToken ct)
{ {
var (task, id, command, ct) = bulkCommand; if (task.Result != null || task.Command == null)
{
return;
}
try try
{ {
await task.Bus.PublishAsync(command, ct); await commandBus.PublishAsync(task.Command, ct);
task.SetResult();
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex));
} }
catch (Exception ex) catch (Exception ex)
{ {
log.LogError(ex, "Failed to execute content bulk job with index {index} of type {type}.", task.SetResult(ex);
task.JobIndex,
task.CommandJob.Type);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex, ex));
} }
} }
private async Task<IEnumerable<BulkTaskCommand>> CreateCommandsAsync(BulkTask task) private async Task<IEnumerable<BulkTask>> CreateTasksAsync(
BulkUpdateJob bulkJob,
BulkUpdateContents bulk,
int jobIndex,
CancellationToken ct)
{ {
// The task parallel pipeline does not allow async-enumerable. // The task parallel pipeline does not allow async-enumerable.
var commands = new List<BulkTaskCommand>(); var tasks = new List<BulkTask>();
try try
{ {
// Check whether another schema is defined for the current job and override the schema id if necessary. var schemaId = bulk.SchemaId;
var overridenSchema = task.CommandJob.Schema;
if (!string.IsNullOrWhiteSpace(overridenSchema)) // Check whether another schema is defined for the current job and override the schema id if necessary.
if (!string.IsNullOrWhiteSpace(bulkJob.Schema))
{ {
var schema = await contentQuery.GetSchemaOrThrowAsync(contextProvider.Context, overridenSchema, task.Aborted); var schema = await contentQuery.GetSchemaOrThrowAsync(contextProvider.Context, bulkJob.Schema, ct);
// Task is immutable, so we have to create a copy. schemaId = schema.NamedId();
task = task with { SchemaId = schema.NamedId() };
} }
// The bulk command can be invoke in a schema controller or without a schema controller, therefore the name might be null. // The bulk command can be invoke in a schema controller or without a schema controller, therefore the name might be null.
if (task.SchemaId == null || task.SchemaId.Id == default) if (schemaId == null || schemaId.Id == default)
{ {
throw new DomainObjectNotFoundException("undefined"); tasks.Add(BulkTask.Failed(bulkJob, bulk, jobIndex, new DomainObjectNotFoundException("undefined")));
return tasks;
} }
var resolvedIds = await FindIdAsync(task, task.SchemaId.Name); var resolvedIds = await FindIdAsync(schemaId, bulkJob, ct);
if (resolvedIds.Length == 0) if (resolvedIds.Length == 0)
{ {
throw new DomainObjectNotFoundException("undefined"); tasks.Add(BulkTask.Failed(bulkJob, bulk, jobIndex, new DomainObjectNotFoundException("undefined")));
return tasks;
} }
foreach (var id in resolvedIds) foreach (var id in resolvedIds)
{ {
try tasks.Add(CreateTask(id, schemaId, bulkJob, bulk, jobIndex));
{
var command = CreateCommand(task);
command.ContentId = id;
commands.Add(new BulkTaskCommand(task, id, command, task.Aborted));
}
catch (Exception ex)
{
log.LogError(ex, "Failed to create content bulk job with index {index} of type {type}.",
task.JobIndex,
task.CommandJob.Type);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex, ex));
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
task.Results.Add(new BulkUpdateResultItem(task.CommandJob.Id, task.JobIndex, ex)); tasks.Add(BulkTask.Failed(bulkJob, bulk, jobIndex, ex));
} }
return commands; return tasks;
} }
private ContentCommand CreateCommand(BulkTask task) private BulkTask CreateTask(
DomainId id,
NamedId<DomainId> schemaId,
BulkUpdateJob bulkJob,
BulkUpdateContents bulk,
int jobIndex)
{ {
var job = task.CommandJob; try
switch (job.Type)
{ {
case BulkUpdateContentType.Create: switch (bulkJob.Type)
{ {
var command = new CreateContent(); case BulkUpdateContentType.Create:
return CreateTask<CreateContent>(id, schemaId, bulkJob, bulk, jobIndex,
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsCreate); PermissionIds.AppContentsCreate);
return command;
}
case BulkUpdateContentType.Update:
{
var command = new UpdateContent();
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsUpdateOwn);
return command;
}
case BulkUpdateContentType.Upsert:
{
var command = new UpsertContent();
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsUpsert);
return command;
}
case BulkUpdateContentType.Patch:
{
var command = new PatchContent();
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsUpdateOwn);
return command;
}
case BulkUpdateContentType.Validate: case BulkUpdateContentType.Update:
{ return CreateTask<UpdateContent>(id, schemaId, bulkJob, bulk, jobIndex,
var command = new ValidateContent(); PermissionIds.AppContentsUpdateOwn);
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsReadOwn); case BulkUpdateContentType.Upsert:
return command; return CreateTask<UpsertContent>(id, schemaId, bulkJob, bulk, jobIndex,
} PermissionIds.AppContentsUpsert);
case BulkUpdateContentType.ChangeStatus: case BulkUpdateContentType.Patch:
{ return CreateTask<PatchContent>(id, schemaId, bulkJob, bulk, jobIndex,
var command = new ChangeContentStatus { Status = job.Status ?? Status.Draft }; PermissionIds.AppContentsUpdateOwn);
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsChangeStatusOwn); case BulkUpdateContentType.Validate:
return command; return CreateTask<ValidateContent>(id, schemaId, bulkJob, bulk, jobIndex,
} PermissionIds.AppContentsReadOwn);
case BulkUpdateContentType.Delete: case BulkUpdateContentType.ChangeStatus:
{ return CreateTask<ChangeContentStatus>(id, schemaId, bulkJob, bulk, jobIndex,
var command = new DeleteContent(); PermissionIds.AppContentsChangeStatusOwn);
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsDeleteOwn); case BulkUpdateContentType.Delete:
return command; return CreateTask<DeleteContent>(id, schemaId, bulkJob, bulk, jobIndex,
} PermissionIds.AppContentsDeleteOwn);
default: default:
ThrowHelper.NotSupportedException(); return BulkTask.Failed(bulkJob, bulk, jobIndex, new NotSupportedException());
return default!; }
}
catch (Exception ex)
{
return BulkTask.Failed(bulkJob, bulk, jobIndex, ex);
} }
} }
private void EnrichAndCheckPermission<T>(BulkTask task, T command, string permissionId) where T : ContentCommand private BulkTask CreateTask<T>(
DomainId id,
NamedId<DomainId> schemaId,
BulkUpdateJob bulkJob,
BulkUpdateContents bulk,
int jobIndex,
string permissionId) where T : ContentCommand, new()
{ {
SimpleMapper.Map(task.Command, command); if (!contextProvider.Context.Allows(permissionId, schemaId.Name))
SimpleMapper.Map(task.CommandJob, command);
if (!contextProvider.Context.Allows(permissionId, command.SchemaId.Name))
{ {
throw new DomainForbiddenException("Forbidden"); return BulkTask.Failed(bulkJob, bulk, jobIndex, new DomainForbiddenException("Forbidden"));
} }
command.SchemaId = task.SchemaId; var command = new T();
command.ExpectedVersion = task.Command.ExpectedVersion;
SimpleMapper.Map(bulk, command);
SimpleMapper.Map(bulkJob, command);
command.ContentId = id;
command.SchemaId = schemaId;
return new BulkTask(bulkJob, bulk, jobIndex, command);
} }
private async Task<DomainId[]> FindIdAsync(BulkTask task, string schema) private async Task<DomainId[]> FindIdAsync(NamedId<DomainId> schemaId, BulkUpdateJob bulkJob,
CancellationToken ct)
{ {
var id = task.CommandJob.Id; var id = bulkJob.Id;
if (id != null) if (id != null)
{ {
return new[] { id.Value }; return new[] { id.Value };
} }
if (task.CommandJob.Query != null) if (bulkJob.Query != null)
{ {
task.CommandJob.Query.Take = task.CommandJob.ExpectedCount; bulkJob.Query.Take = bulkJob.ExpectedCount;
var existingQuery = Q.Empty.WithJsonQuery(task.CommandJob.Query); var existingQuery = Q.Empty.WithJsonQuery(bulkJob.Query);
var existingResult = await contentQuery.QueryAsync(contextProvider.Context, schema, existingQuery, task.Aborted); var existingResult = await contentQuery.QueryAsync(contextProvider.Context, schemaId.Id.ToString(), existingQuery, ct);
if (existingResult.Total > task.CommandJob.ExpectedCount) if (existingResult.Total > bulkJob.ExpectedCount)
{ {
throw new DomainException(T.Get("contents.bulkInsertQueryNotUnique")); throw new DomainException(T.Get("contents.bulkInsertQueryNotUnique"));
} }
// Upsert means that we either update the content if we find it or that we create a new one. // Upsert means that we either update the content if we find it or that we create a new one.
// Therefore we create a new ID if we cannot find the ID for the query. // Therefore we create a new ID if we cannot find the ID for the query.
if (existingResult.Count == 0 && task.CommandJob.Type == BulkUpdateContentType.Upsert) if (existingResult.Count == 0 && bulkJob.Type == BulkUpdateContentType.Upsert)
{ {
return new[] { DomainId.NewGuid() }; return new[] { DomainId.NewGuid() };
} }
@ -327,7 +262,7 @@ public sealed class ContentsBulkUpdateCommandMiddleware : ICommandMiddleware
return existingResult.Select(x => x.Id).ToArray(); return existingResult.Select(x => x.Id).ToArray();
} }
if (task.CommandJob.Type is BulkUpdateContentType.Create or BulkUpdateContentType.Upsert) if (bulkJob.Type is BulkUpdateContentType.Create or BulkUpdateContentType.Upsert)
{ {
return new[] { DomainId.NewGuid() }; return new[] { DomainId.NewGuid() };
} }

26
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -17,10 +17,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL;
public sealed class GraphQLExecutionContext : QueryExecutionContext public sealed class GraphQLExecutionContext : QueryExecutionContext
{ {
private const int MaxBatchSize = 5000;
private const int MinBatchSize = 1;
private static readonly EmptyDataLoaderResult<IEnrichedAssetEntity> EmptyAssets = new EmptyDataLoaderResult<IEnrichedAssetEntity>(); private static readonly EmptyDataLoaderResult<IEnrichedAssetEntity> EmptyAssets = new EmptyDataLoaderResult<IEnrichedAssetEntity>();
private static readonly EmptyDataLoaderResult<IEnrichedContentEntity> EmptyContents = new EmptyDataLoaderResult<IEnrichedContentEntity>(); private static readonly EmptyDataLoaderResult<IEnrichedContentEntity> EmptyContents = new EmptyDataLoaderResult<IEnrichedContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaders; private readonly IDataLoaderContextAccessor dataLoaders;
private readonly GraphQLOptions options; private readonly GraphQLOptions options;
private readonly int batchSize;
public override Context Context { get; } public override Context Context { get; }
@ -38,11 +41,22 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
this.dataLoaders = dataLoaders; this.dataLoaders = dataLoaders;
Context = context.Clone(b => b Context = context.Clone(b => b
.WithoutCleanup() .WithNoCleanup()
.WithoutContentEnrichment() .WithNoEnrichment()
.WithoutAssetEnrichment()); .WithNoAssetEnrichment());
this.options = options.Value; this.options = options.Value;
batchSize = Context.BatchSize();
if (batchSize == 0)
{
batchSize = options.Value.DataLoaderBatchSize;
}
else
{
batchSize = Math.Max(MinBatchSize, Math.Min(MaxBatchSize, batchSize));
}
} }
public async ValueTask<IUser?> FindUserAsync(RefToken refToken, public async ValueTask<IUser?> FindUserAsync(RefToken refToken,
@ -121,7 +135,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
var result = await QueryAssetsByIdsAsync(batch, ct); var result = await QueryAssetsByIdsAsync(batch, ct);
return result.ToDictionary(x => x.Id); return result.ToDictionary(x => x.Id);
}, maxBatchSize: options.DataLoaderBatchSize); }, maxBatchSize: batchSize);
} }
private IDataLoader<CacheableId<DomainId>, IEnrichedContentEntity> GetContentsLoader() private IDataLoader<CacheableId<DomainId>, IEnrichedContentEntity> GetContentsLoader()
@ -132,7 +146,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
var result = await QueryContentsByIdsAsync(batch, null, ct); var result = await QueryContentsByIdsAsync(batch, null, ct);
return result.ToDictionary(x => x.Id); return result.ToDictionary(x => x.Id);
}, maxBatchSize: options.DataLoaderBatchSize); }, maxBatchSize: batchSize);
} }
private IDataLoader<(DomainId Id, HashSet<string> Fields), IEnrichedContentEntity> GetContentsLoaderWithFields() private IDataLoader<(DomainId Id, HashSet<string> Fields), IEnrichedContentEntity> GetContentsLoaderWithFields()
@ -145,7 +159,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
var result = await QueryContentsByIdsAsync(batch.Select(x => x.Id), fields, ct); var result = await QueryContentsByIdsAsync(batch.Select(x => x.Id), fields, ct);
return result.ToDictionary(x => (x.Id, fields)); return result.ToDictionary(x => (x.Id, fields));
}, maxBatchSize: options.DataLoaderBatchSize); }, maxBatchSize: batchSize);
} }
private IDataLoader<string, IUser> GetUserLoader() private IDataLoader<string, IUser> GetUserLoader()

20
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs

@ -322,24 +322,4 @@ internal sealed class FieldVisitor : IFieldVisitor<FieldGraphSchema, FieldInfo>
return null; return null;
}); });
} }
private static IFieldResolver CreateAsyncValueResolver<T>(AsyncValueResolver<T> valueResolver)
{
return Resolvers.Async<IReadOnlyDictionary<string, JsonValue>, object?>(async (source, fieldContext, context) =>
{
var key = fieldContext.FieldDefinition.SourceName();
if (source.TryGetValue(key, out var value))
{
if (value == JsonValue.Null)
{
return null;
}
return await valueResolver(value, fieldContext, context);
}
return null;
});
}
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs

@ -60,13 +60,13 @@ public class ContentQueryParser
WithPaging(query, q); WithPaging(query, q);
q = q.WithQuery(query); q = q.WithQuery(query);
q = q.WithFields(context.FieldsList()); q = q.WithFields(context.Fields());
if (context.ShouldSkipTotal()) if (context.NoTotal())
{ {
q = q.WithoutTotal(); q = q.WithoutTotal();
} }
else if (context.ShouldSkipSlowTotal()) else if (context.NoSlowTotal())
{ {
q = q.WithoutSlowTotal(); q = q.WithoutSlowTotal();
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -52,9 +52,9 @@ public sealed class ContentQueryService : IContentQueryService
// Skip all expensive operations when we call the enricher. // Skip all expensive operations when we call the enricher.
context = context.Clone(b => b context = context.Clone(b => b
.WithoutScripting() .WithNoScripting()
.WithoutCacheKeys() .WithNoCacheKeys()
.WithoutContentEnrichment()); .WithNoEnrichment());
// We run this query without a timeout because it is meant for long running background operations. // We run this query without a timeout because it is meant for long running background operations.
var contents = contentRepository.StreamAll(context.App.Id, HashSet.Of(schema.Id), context.Scope(), ct); var contents = contentRepository.StreamAll(context.App.Id, HashSet.Of(schema.Id), context.Scope(), ct);

8
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs

@ -60,7 +60,7 @@ public sealed class ConvertData : IContentEnricherStep
private async Task<ValueReferencesConverter?> CleanReferencesAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas, private async Task<ValueReferencesConverter?> CleanReferencesAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas,
CancellationToken ct) CancellationToken ct)
{ {
if (context.ShouldSkipCleanup()) if (context.NoCleanup())
{ {
return null; return null;
} }
@ -136,14 +136,14 @@ public sealed class ConvertData : IContentEnricherStep
converter.Add( converter.Add(
new ResolveLanguages( new ResolveLanguages(
context.App.Languages, context.App.Languages,
context.LanguagesList().ToArray()) context.Languages().ToArray())
{ {
ResolveFallback = !context.IsFrontendClient && context.ShouldResolveLanguages() ResolveFallback = !context.IsFrontendClient && !context.NoResolveLanguages()
}); });
if (!context.IsFrontendClient) if (!context.IsFrontendClient)
{ {
var assetUrls = context.AssetUrls().ToList(); var assetUrls = context.ResolveUrls().ToList();
if (assetUrls.Count > 0) if (assetUrls.Count > 0)
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs

@ -61,6 +61,6 @@ public sealed class EnrichForCaching : IContentEnricherStep
private static bool ShouldEnrich(Context context) private static bool ShouldEnrich(Context context)
{ {
return !context.ShouldSkipCacheKeys(); return !context.NoCacheKeys();
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs

@ -105,6 +105,6 @@ public sealed class EnrichWithWorkflows : IContentEnricherStep
private static bool ShouldEnrichWithStatuses(Context context) private static bool ShouldEnrichWithStatuses(Context context)
{ {
return context.IsFrontendClient || context.ShouldResolveFlow(); return context.IsFrontendClient || context.ResolveFlow();
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs

@ -131,8 +131,8 @@ public sealed class ResolveAssets : IContentEnricherStep
} }
var queryContext = context.Clone(b => b var queryContext = context.Clone(b => b
.WithoutAssetEnrichment(true) .WithNoAssetEnrichment(true)
.WithoutTotal()); .WithNoTotal());
var assets = await assetQuery.QueryAsync(queryContext, null, Q.Empty.WithIds(ids), ct); var assets = await assetQuery.QueryAsync(queryContext, null, Q.Empty.WithIds(ids), ct);
@ -149,6 +149,6 @@ public sealed class ResolveAssets : IContentEnricherStep
private static bool ShouldEnrich(Context context) private static bool ShouldEnrich(Context context)
{ {
return context.IsFrontendClient && !context.ShouldSkipContentEnrichment(); return context.IsFrontendClient && !context.NoEnrichment();
} }
} }

8
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs

@ -159,9 +159,11 @@ public sealed class ResolveReferences : IContentEnricherStep
return EmptyContents; return EmptyContents;
} }
// Ensure that we reset the fields to not use the field selection from the parent query.
var queryContext = context.Clone(b => b var queryContext = context.Clone(b => b
.WithoutContentEnrichment(true) .WithFields(null)
.WithoutTotal()); .WithNoEnrichment(true)
.WithNoTotal());
var references = await ContentQuery.QueryAsync(queryContext, Q.Empty.WithIds(ids).WithoutTotal(), ct); var references = await ContentQuery.QueryAsync(queryContext, Q.Empty.WithIds(ids).WithoutTotal(), ct);
@ -170,6 +172,6 @@ public sealed class ResolveReferences : IContentEnricherStep
private static bool ShouldEnrich(Context context) private static bool ShouldEnrich(Context context)
{ {
return context.IsFrontendClient && !context.ShouldSkipContentEnrichment(); return context.IsFrontendClient && !context.NoEnrichment();
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs

@ -97,7 +97,7 @@ public sealed class ScriptContent : IContentEnricherStep
{ {
// We need a special permission to disable scripting for security reasons, if the script removes sensible data. // We need a special permission to disable scripting for security reasons, if the script removes sensible data.
var shouldScript = var shouldScript =
!context.ShouldSkipScripting() || !context.NoScripting() ||
!context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name)); !context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name));
return !context.IsFrontendClient && shouldScript; return !context.IsFrontendClient && shouldScript;

5
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs

@ -94,9 +94,10 @@ public sealed class ReferencesFluidExtension : IFluidExtension
var requestContext = var requestContext =
Context.Admin(app).Clone(b => b Context.Admin(app).Clone(b => b
.WithoutContentEnrichment() .WithFields(null)
.WithNoEnrichment()
.WithUnpublished() .WithUnpublished()
.WithoutTotal()); .WithNoTotal());
var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(domainId)); var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(domainId));

10
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs

@ -83,9 +83,10 @@ public sealed class ReferencesJintExtension : IJintExtension, IScriptDescriptor
var requestContext = var requestContext =
new Context(user, app).Clone(b => b new Context(user, app).Clone(b => b
.WithoutContentEnrichment() .WithFields(null)
.WithNoEnrichment()
.WithUnpublished() .WithUnpublished()
.WithoutTotal()); .WithNoTotal());
var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct); var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct);
@ -122,9 +123,10 @@ public sealed class ReferencesJintExtension : IJintExtension, IScriptDescriptor
var requestContext = var requestContext =
new Context(user, app).Clone(b => b new Context(user, app).Clone(b => b
.WithoutContentEnrichment() .WithFields(null)
.WithNoEnrichment()
.WithUnpublished() .WithUnpublished()
.WithoutTotal()); .WithNoTotal());
var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct); var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct);

94
backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs

@ -5,53 +5,82 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Globalization;
namespace Squidex.Domain.Apps.Entities; namespace Squidex.Domain.Apps.Entities;
public static class ContextHeaders public static class ContextHeaders
{ {
public const string NoCacheKeys = "X-NoCacheKeys"; private static readonly char[] Separators = { ',', ';' };
public const string NoScripting = "X-NoScripting";
public const string NoSlowTotal = "X-NoSlowTotal"; public const string KeyBatchSize = "X-BatchSize";
public const string NoTotal = "X-NoTotal"; public const string KeyNoCacheKeys = "X-NoCacheKeys";
public const string KeyNoScripting = "X-NoScripting";
public const string KeyNoSlowTotal = "X-NoSlowTotal";
public const string KeyNoTotal = "X-NoTotal";
public static int BatchSize(this Context context)
{
return context.AsNumber(KeyBatchSize);
}
public static bool ShouldSkipCacheKeys(this Context context) public static ICloneBuilder WithBatchSize(this ICloneBuilder builder, int value)
{ {
return context.Headers.ContainsKey(NoCacheKeys); return builder.WithNumber(KeyBatchSize, value);
} }
public static ICloneBuilder WithoutCacheKeys(this ICloneBuilder builder, bool value = true) public static bool NoCacheKeys(this Context context)
{ {
return builder.WithBoolean(NoCacheKeys, value); return context.AsBoolean(KeyNoCacheKeys);
} }
public static bool ShouldSkipScripting(this Context context) public static ICloneBuilder WithNoCacheKeys(this ICloneBuilder builder, bool value = true)
{ {
return context.Headers.ContainsKey(NoScripting); return builder.WithBoolean(KeyNoCacheKeys, value);
} }
public static ICloneBuilder WithoutScripting(this ICloneBuilder builder, bool value = true) public static bool NoScripting(this Context context)
{ {
return builder.WithBoolean(NoScripting, value); return context.AsBoolean(KeyNoScripting);
} }
public static bool ShouldSkipTotal(this Context context) public static ICloneBuilder WithNoScripting(this ICloneBuilder builder, bool value = true)
{ {
return context.Headers.ContainsKey(NoTotal); return builder.WithBoolean(KeyNoScripting, value);
} }
public static ICloneBuilder WithoutTotal(this ICloneBuilder builder, bool value = true) public static bool NoTotal(this Context context)
{ {
return builder.WithBoolean(NoTotal, value); return context.AsBoolean(KeyNoTotal);
} }
public static bool ShouldSkipSlowTotal(this Context context) public static ICloneBuilder WithNoTotal(this ICloneBuilder builder, bool value = true)
{ {
return context.Headers.ContainsKey(NoSlowTotal); return builder.WithBoolean(KeyNoTotal, value);
} }
public static ICloneBuilder WithoutSlowTotal(this ICloneBuilder builder, bool value = true) public static bool NoSlowTotal(this Context context)
{ {
return builder.WithBoolean(NoSlowTotal, value); return context.AsBoolean(KeyNoSlowTotal);
}
public static ICloneBuilder WithNoSlowTotal(this ICloneBuilder builder, bool value = true)
{
return builder.WithBoolean(KeyNoSlowTotal, value);
}
public static ICloneBuilder WithNumber(this ICloneBuilder builder, string key, int value)
{
if (value != 0)
{
builder.SetHeader(key, value.ToString(CultureInfo.InvariantCulture));
}
else
{
builder.Remove(key);
}
return builder;
} }
public static ICloneBuilder WithBoolean(this ICloneBuilder builder, string key, bool value) public static ICloneBuilder WithBoolean(this ICloneBuilder builder, string key, bool value)
@ -81,4 +110,29 @@ public static class ContextHeaders
return builder; return builder;
} }
public static bool AsBoolean(this Context context, string key)
{
return context.Headers.ContainsKey(key);
}
public static int AsNumber(this Context context, string key)
{
if (context.Headers.TryGetValue(key, out var value) && int.TryParse(value, CultureInfo.InvariantCulture, out var result))
{
return result;
}
return 0;
}
public static IEnumerable<string> AsStrings(this Context context, string key)
{
if (context.Headers.TryGetValue(key, out var value))
{
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).Distinct();
}
return Enumerable.Empty<string>();
}
} }

2
backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs

@ -50,7 +50,7 @@ public sealed class RuleEnricher : IRuleEnricher
} }
// Sometimes we just want to skip this for performance reasons. // Sometimes we just want to skip this for performance reasons.
var enrichCacheKeys = !context.ShouldSkipCacheKeys(); var enrichCacheKeys = !context.NoCacheKeys();
foreach (var group in results.GroupBy(x => x.AppId.Id)) foreach (var group in results.GroupBy(x => x.AppId.Id))
{ {

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

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading.Tasks.Dataflow;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
@ -22,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class RuleDequeuerWorker : IBackgroundProcess public sealed class RuleDequeuerWorker : IBackgroundProcess
{ {
private readonly ConcurrentDictionary<DomainId, bool> executing = new ConcurrentDictionary<DomainId, bool>(); private readonly ConcurrentDictionary<DomainId, bool> executing = new ConcurrentDictionary<DomainId, bool>();
private readonly ITargetBlock<IRuleEventEntity> requestBlock; private readonly PartitionedScheduler<IRuleEventEntity> requestScheduler;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleService ruleService; private readonly IRuleService ruleService;
private readonly IRuleUsageTracker ruleUsageTracker; private readonly IRuleUsageTracker ruleUsageTracker;
@ -42,9 +41,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
this.ruleUsageTracker = ruleUsageTracker; this.ruleUsageTracker = ruleUsageTracker;
this.log = log; this.log = log;
requestBlock = requestScheduler = new PartitionedScheduler<IRuleEventEntity>(HandleAsync, 32, 2);
new PartitionedActionBlock<IRuleEventEntity>(HandleAsync, x => x.Job.ExecutionPartition,
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 });
} }
public Task StartAsync( public Task StartAsync(
@ -60,9 +57,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
{ {
await (timer?.StopAsync() ?? Task.CompletedTask); await (timer?.StopAsync() ?? Task.CompletedTask);
requestBlock.Complete(); await requestScheduler.CompleteAsync();
await requestBlock.Completion;
} }
public async Task QueryAsync( public async Task QueryAsync(
@ -72,7 +67,10 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
{ {
var now = Clock.GetCurrentInstant(); var now = Clock.GetCurrentInstant();
await ruleEventRepository.QueryPendingAsync(now, requestBlock.SendAsync, ct); await ruleEventRepository.QueryPendingAsync(now, async @event =>
{
await requestScheduler.ScheduleAsync(@event.Job.ExecutionPartition, @event, ct);
}, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -80,7 +78,8 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
} }
} }
public async Task HandleAsync(IRuleEventEntity @event) public async Task HandleAsync(IRuleEventEntity @event,
CancellationToken ct)
{ {
if (!executing.TryAdd(@event.Id, false)) if (!executing.TryAdd(@event.Id, false))
{ {
@ -91,7 +90,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
{ {
var job = @event.Job; var job = @event.Job;
var (response, elapsed) = await ruleService.InvokeAsync(job.ActionName, job.ActionData); var (response, elapsed) = await ruleService.InvokeAsync(job.ActionName, job.ActionData, ct);
var jobDelay = ComputeJobDelay(response.Status, @event, job); var jobDelay = ComputeJobDelay(response.Status, @event, job);
var jobResult = ComputeJobResult(response.Status, jobDelay); var jobResult = ComputeJobResult(response.Status, jobDelay);
@ -108,11 +107,11 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
JobResult = jobResult JobResult = jobResult
}; };
await ruleEventRepository.UpdateAsync(@event.Job, update); await ruleEventRepository.UpdateAsync(@event.Job, update, default);
if (response.Status == RuleResult.Failed) if (response.Status == RuleResult.Failed)
{ {
await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 0, 1); await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 0, 1, default);
log.LogWarning(response.Exception, "Failed to execute rule event with rule id {ruleId}/{description}.", log.LogWarning(response.Exception, "Failed to execute rule event with rule id {ruleId}/{description}.",
@event.Job.RuleId, @event.Job.RuleId,
@ -120,7 +119,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
} }
else else
{ {
await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 1, 0); await ruleUsageTracker.TrackAsync(job.AppId, job.RuleId, now.ToDateOnly(), 0, 1, 0, default);
} }
} }
catch (Exception ex) catch (Exception ex)

46
backend/src/Squidex.Domain.Apps.Entities/Tags/TagService.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks.Dataflow;
using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
@ -312,48 +311,15 @@ public sealed class TagService : ITagService
public async Task ClearAsync( public async Task ClearAsync(
CancellationToken ct = default) CancellationToken ct = default)
{ {
var writerBlock = new ActionBlock<SnapshotResult<State>[]>(async batch => // Run batch first, because it is cheaper as it has less items.
{ var batches = persistenceFactory.Snapshots.ReadAllAsync(ct).Batch(500, ct).Buffered(2, ct: ct);
try
{
var isChanged = !batch.All(x => !x.Value.Clear());
if (isChanged)
{
var jobs = batch.Select(x => new SnapshotWriteJob<State>(x.Key, x.Value, x.Version));
await persistenceFactory.Snapshots.WriteManyAsync(jobs, ct); await Parallel.ForEachAsync(batches, ct, async (batch, ct) =>
}
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
},
new ExecutionDataflowBlockOptions
{ {
BoundedCapacity = 2, // Convert to list for the tests, actually not needed.
MaxDegreeOfParallelism = 1, var jobs = batch.Where(x => x.Value.Clear()).Select(x => new SnapshotWriteJob<State>(x.Key, x.Value, x.Version)).ToList();
MaxMessagesPerTask = 1,
});
// Create batches of 500 items to clear the tag count for better performance. await persistenceFactory.Snapshots.WriteManyAsync(jobs, ct);
var batchBlock = new BatchBlock<SnapshotResult<State>>(500, new GroupingDataflowBlockOptions
{
BoundedCapacity = 500
}); });
batchBlock.BidirectionalLinkTo(writerBlock);
await foreach (var state in persistenceFactory.Snapshots.ReadAllAsync(ct))
{
// Uses back-propagation to not query additional items from the database, when queue is full.
await batchBlock.SendAsync(state, ct);
}
batchBlock.Complete();
await writerBlock.Completion;
} }
} }

1
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -22,7 +22,6 @@
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.20.0" /> <PackageReference Include="MongoDB.Driver.GridFS" Version="2.20.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="7.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -91,31 +92,48 @@ public static class CollectionExtensions
return source.Count == other.Count && source.Intersect(other).Count() == other.Count; return source.Count == other.Count && source.Intersect(other).Count() == other.Count;
} }
public static IEnumerable<IEnumerable<TSource>> Batch<TSource>(this IEnumerable<TSource> source, int size) public static IEnumerable<List<T>> Batch<T>(this IEnumerable<T> source, int size)
{ {
TSource[]? bucket = null; List<T>? bucket = null;
var bucketIndex = 0;
foreach (var item in source) foreach (var item in source)
{ {
bucket ??= new TSource[size]; bucket ??= new List<T>(size);
bucket[bucketIndex++] = item; bucket.Add(item);
if (bucketIndex != size) if (bucket.Count == size)
{ {
continue; yield return bucket;
bucket = null;
} }
}
if (bucket?.Count > 0)
{
yield return bucket; yield return bucket;
}
}
public static async IAsyncEnumerable<List<T>> Batch<T>(this IAsyncEnumerable<T> source, int size,
[EnumeratorCancellation] CancellationToken ct = default)
{
List<T>? bucket = null;
await foreach (var item in source.WithCancellation(ct))
{
bucket ??= new List<T>(size);
bucket.Add(item);
bucket = null; if (bucket.Count == size)
bucketIndex = 0; {
yield return bucket;
bucket = null;
}
} }
if (bucket != null && bucketIndex > 0) if (bucket?.Count > 0)
{ {
yield return bucket.Take(bucketIndex); yield return bucket;
} }
} }
@ -471,4 +489,26 @@ public static class CollectionExtensions
} }
} }
} }
public static async Task<IReadOnlyCollection<TResult>> SelectManyAsync<T, TResult>(this IEnumerable<T> source, Func<T, int, CancellationToken, Task<IEnumerable<TResult>>> selector,
CancellationToken ct = default)
{
var result = new ConcurrentBag<TResult>();
var sourceWithIndex = source.Select((x, i) => (Item: x, Index: i));
await Parallel.ForEachAsync(sourceWithIndex,
ct,
async (item, ct) =>
{
var createdItems = await selector(item.Item, item.Index, ct);
foreach (var created in createdItems)
{
result.Add(created);
}
});
return result;
}
} }

77
backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks.Dataflow;
using Google.Api;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Squidex.Caching; using Squidex.Caching;
@ -93,75 +91,40 @@ public class Rebuilder
{ {
var store = serviceProvider.GetRequiredService<IStore<TState>>(); var store = serviceProvider.GetRequiredService<IStore<TState>>();
var parallelism = Environment.ProcessorCount;
var handledIds = new HashSet<DomainId>(); var handledIds = new HashSet<DomainId>();
var handlerErrors = 0; var handlerErrors = 0;
using (localCache.StartContext()) using (localCache.StartContext())
{ {
var workerBlock = new ActionBlock<DomainId[]>(async ids => // Run batch first, because it is cheaper as it has less items.
var batches = source.Where(handledIds.Add).Batch(batchSize, ct).Buffered(2, ct);
await Parallel.ForEachAsync(batches, ct, async (batch, ct) =>
{ {
try await using (var context = store.WithBatchContext(typeof(T)))
{ {
await using (var context = store.WithBatchContext(typeof(T))) await context.LoadAsync(batch);
foreach (var id in batch)
{ {
await context.LoadAsync(ids); try
{
var domainObject = domainObjectFactory.Create<T, TState>(id, context);
foreach (var id in ids) await domainObject.RebuildStateAsync(ct);
}
catch (DomainObjectNotFoundException)
{ {
try return;
{ }
var domainObject = domainObjectFactory.Create<T, TState>(id, context); catch (Exception ex)
{
await domainObject.RebuildStateAsync(ct); log.LogWarning(ex, "Found corrupt domain object of type {type} with ID {id}.", typeof(T), id);
} Interlocked.Increment(ref handlerErrors);
catch (DomainObjectNotFoundException)
{
return;
}
catch (Exception ex)
{
log.LogWarning(ex, "Found corrupt domain object of type {type} with ID {id}.", typeof(T), id);
Interlocked.Increment(ref handlerErrors);
}
} }
} }
} }
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
},
new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = parallelism,
MaxMessagesPerTask = 10,
BoundedCapacity = parallelism
});
var batchBlock = new BatchBlock<DomainId>(batchSize, new GroupingDataflowBlockOptions
{
BoundedCapacity = batchSize
}); });
batchBlock.BidirectionalLinkTo(workerBlock);
await foreach (var id in source.WithCancellation(ct))
{
if (handledIds.Add(id))
{
if (!await batchBlock.SendAsync(id, ct))
{
break;
}
}
}
batchBlock.Complete();
await workerBlock.Completion;
} }
var errorRate = (double)handlerErrors / handledIds.Count; var errorRate = (double)handlerErrors / handledIds.Count;

82
backend/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs

@ -15,6 +15,13 @@ namespace Squidex.Infrastructure.Reflection;
public static class SimpleMapper public static class SimpleMapper
{ {
private readonly record struct MappingContext
{
required public CultureInfo Culture { get; init; }
required public bool NullableAsOptional { get; init; }
}
private sealed class StringConversionPropertyMapper : PropertyMapper private sealed class StringConversionPropertyMapper : PropertyMapper
{ {
public StringConversionPropertyMapper( public StringConversionPropertyMapper(
@ -24,7 +31,7 @@ public static class SimpleMapper
{ {
} }
public override void MapProperty(object source, object target, CultureInfo culture) public override void MapProperty(object source, object target, ref MappingContext context)
{ {
var value = GetValue(source); var value = GetValue(source);
@ -32,6 +39,39 @@ public static class SimpleMapper
} }
} }
private sealed class NullablePropertyMapper : PropertyMapper
{
private readonly object? defaultValue;
public NullablePropertyMapper(
PropertyAccessor sourceAccessor,
PropertyAccessor targetAccessor,
object? defaultValue)
: base(sourceAccessor, targetAccessor)
{
this.defaultValue = defaultValue;
}
public override void MapProperty(object source, object target, ref MappingContext context)
{
var value = GetValue(source);
if (value == null)
{
if (context.NullableAsOptional)
{
return;
}
else
{
value = defaultValue;
}
}
SetValue(target, value);
}
}
private sealed class ConversionPropertyMapper : PropertyMapper private sealed class ConversionPropertyMapper : PropertyMapper
{ {
private readonly Type targetType; private readonly Type targetType;
@ -45,7 +85,7 @@ public static class SimpleMapper
this.targetType = targetType; this.targetType = targetType;
} }
public override void MapProperty(object source, object target, CultureInfo culture) public override void MapProperty(object source, object target, ref MappingContext context)
{ {
var value = GetValue(source); var value = GetValue(source);
@ -56,7 +96,7 @@ public static class SimpleMapper
try try
{ {
var converted = Convert.ChangeType(value, targetType, culture); var converted = Convert.ChangeType(value, targetType, context.Culture);
SetValue(target, converted); SetValue(target, converted);
} }
@ -80,7 +120,7 @@ public static class SimpleMapper
this.converter = converter; this.converter = converter;
} }
public override void MapProperty(object source, object target, CultureInfo culture) public override void MapProperty(object source, object target, ref MappingContext context)
{ {
var value = GetValue(source); var value = GetValue(source);
@ -91,7 +131,7 @@ public static class SimpleMapper
try try
{ {
var converted = converter.ConvertFrom(null, culture, value); var converted = converter.ConvertFrom(null, context.Culture, value);
SetValue(target, converted); SetValue(target, converted);
} }
@ -113,7 +153,7 @@ public static class SimpleMapper
this.targetAccessor = targetAccessor; this.targetAccessor = targetAccessor;
} }
public virtual void MapProperty(object source, object target, CultureInfo culture) public virtual void MapProperty(object source, object target, ref MappingContext context)
{ {
var value = GetValue(source); var value = GetValue(source);
@ -171,6 +211,13 @@ public static class SimpleMapper
new PropertyAccessor(sourceProperty), new PropertyAccessor(sourceProperty),
new PropertyAccessor(targetProperty))); new PropertyAccessor(targetProperty)));
} }
else if (IsNullableOf(sourceType, targetType))
{
Mappers.Add(new NullablePropertyMapper(
new PropertyAccessor(sourceProperty),
new PropertyAccessor(targetProperty),
Activator.CreateInstance(targetType)));
}
else else
{ {
var converter = TypeDescriptor.GetConverter(targetType); var converter = TypeDescriptor.GetConverter(targetType);
@ -191,15 +238,22 @@ public static class SimpleMapper
} }
} }
} }
static bool IsNullableOf(Type type, Type wrappedType)
{
return type.IsGenericType &&
type.GetGenericTypeDefinition() == typeof(Nullable<>) &&
type.GenericTypeArguments[0] == wrappedType;
}
} }
public static TTarget MapClass(TSource source, TTarget destination, CultureInfo culture) public static TTarget MapClass(TSource source, TTarget destination, ref MappingContext context)
{ {
for (var i = 0; i < Mappers.Count; i++) for (var i = 0; i < Mappers.Count; i++)
{ {
var mapper = Mappers[i]; var mapper = Mappers[i];
mapper.MapProperty(source, destination, culture); mapper.MapProperty(source, destination, ref context);
} }
return destination; return destination;
@ -210,10 +264,10 @@ public static class SimpleMapper
where TSource : class where TSource : class
where TTarget : class where TTarget : class
{ {
return Map(source, target, CultureInfo.CurrentCulture); return Map(source, target, CultureInfo.CurrentCulture, true);
} }
public static TTarget Map<TSource, TTarget>(TSource source, TTarget target, CultureInfo culture) public static TTarget Map<TSource, TTarget>(TSource source, TTarget target, CultureInfo culture, bool nullableAsOptional)
where TSource : class where TSource : class
where TTarget : class where TTarget : class
{ {
@ -221,6 +275,12 @@ public static class SimpleMapper
Guard.NotNull(culture); Guard.NotNull(culture);
Guard.NotNull(target); Guard.NotNull(target);
return ClassMapper<TSource, TTarget>.MapClass(source, target, culture); var context = new MappingContext
{
Culture = culture,
NullableAsOptional = nullableAsOptional
};
return ClassMapper<TSource, TTarget>.MapClass(source, target, ref context);
} }
} }

1
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -37,7 +37,6 @@
<PackageReference Include="System.Reactive" Version="6.0.0" /> <PackageReference Include="System.Reactive" Version="6.0.0" />
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" /> <PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" /> <PackageReference Include="System.Security.Claims" Version="4.3.0" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="7.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" /> <AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />

116
backend/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs

@ -1,116 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
namespace Squidex.Infrastructure.Tasks;
public sealed class PartitionedActionBlock<TInput> : ITargetBlock<TInput>
{
private readonly ITargetBlock<TInput> distributor;
private readonly ActionBlock<TInput>[] workers;
public Task Completion
{
get => Task.WhenAll(workers.Select(x => x.Completion));
}
public PartitionedActionBlock(Func<TInput, Task> action, Func<TInput, long> partitioner)
: this(action, partitioner, new ExecutionDataflowBlockOptions())
{
}
public PartitionedActionBlock(Func<TInput, Task> action, Func<TInput, long> partitioner, ExecutionDataflowBlockOptions dataflowBlockOptions)
{
Guard.NotNull(action);
Guard.NotNull(partitioner);
Guard.NotNull(dataflowBlockOptions);
Guard.GreaterThan(dataflowBlockOptions.MaxDegreeOfParallelism, 1, nameof(dataflowBlockOptions.MaxDegreeOfParallelism));
workers = new ActionBlock<TInput>[dataflowBlockOptions.MaxDegreeOfParallelism];
for (var i = 0; i < dataflowBlockOptions.MaxDegreeOfParallelism; i++)
{
workers[i] = new ActionBlock<TInput>(action, new ExecutionDataflowBlockOptions
{
BoundedCapacity = dataflowBlockOptions.BoundedCapacity,
CancellationToken = dataflowBlockOptions.CancellationToken,
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = 1,
TaskScheduler = dataflowBlockOptions.TaskScheduler
});
}
distributor = new ActionBlock<TInput>(async input =>
{
try
{
var partition = Math.Abs(partitioner(input)) % workers.Length;
await workers[partition].SendAsync(input);
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
}, new ExecutionDataflowBlockOptions
{
BoundedCapacity = 1,
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = 1
});
LinkCompletion();
}
public DataflowMessageStatus OfferMessage(DataflowMessageHeader messageHeader, TInput messageValue, ISourceBlock<TInput>? source, bool consumeToAccept)
{
return distributor.OfferMessage(messageHeader, messageValue, source, consumeToAccept);
}
public void Complete()
{
distributor.Complete();
}
public void Fault(Exception exception)
{
distributor.Fault(exception);
}
#pragma warning disable RECS0165 // Asynchronous methods should return a Task instead of void
private async void LinkCompletion()
#pragma warning restore RECS0165 // Asynchronous methods should return a Task instead of void
{
try
{
await distributor.Completion.ConfigureAwait(false);
}
#pragma warning disable RECS0022 // A catch clause that catches System.Exception and has an empty body
catch
#pragma warning restore RECS0022 // A catch clause that catches System.Exception and has an empty body
{
// we do not want to change the stacktrace of the exception.
}
if (distributor.Completion.IsFaulted && distributor.Completion.Exception != null)
{
foreach (var worker in workers)
{
((IDataflowBlock)worker).Fault(distributor.Completion.Exception);
}
}
else
{
foreach (var worker in workers)
{
worker.Complete();
}
}
}
}

109
backend/src/Squidex.Infrastructure/Tasks/PartitionedScheduler.cs

@ -0,0 +1,109 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Channels;
namespace Squidex.Infrastructure.Tasks;
public sealed class PartitionedScheduler<T> : IAsyncDisposable
{
private readonly Consumer[] consumers;
private Exception? exception;
private sealed class Consumer
{
private readonly Channel<T> channel;
private readonly Task worker;
public Consumer(Func<T, CancellationToken, Task> action, int bufferSize,
CancellationToken ct)
{
channel = Channel.CreateBounded<T>(new BoundedChannelOptions(bufferSize)
{
SingleReader = true,
SingleWriter = false
});
worker = Task.Run(async () =>
{
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
await action(item, ct);
}
}, ct);
}
public ValueTask ScheduleAsync(T item,
CancellationToken ct)
{
return channel.Writer.WriteAsync(item, ct);
}
public Task CompleteAsync()
{
channel.Writer.TryComplete();
return worker;
}
}
public PartitionedScheduler(Func<T, CancellationToken, Task> action,
int maxWorkers,
int maxBuffer,
CancellationToken ct = default)
{
consumers = new Consumer[maxWorkers];
for (var i = 0; i < maxWorkers; i++)
{
consumers[i] = new Consumer(action, maxBuffer, ct);
}
}
public async ValueTask ScheduleAsync(object key, T item,
CancellationToken ct = default)
{
if (exception != null)
{
throw exception;
}
var consumerIndex = Math.Abs((key?.GetHashCode() ?? 0) % consumers.Length);
var consumerInstance = consumers[consumerIndex];
try
{
await consumerInstance.ScheduleAsync(item, ct);
}
catch (Exception ex)
{
exception = ex;
}
}
public async Task CompleteAsync()
{
foreach (var consumer in consumers)
{
#pragma warning disable RECS0022 // A catch clause that catches System.Exception and has an empty body
try
{
await consumer.CompleteAsync();
}
catch
{
// Ensure we can complete all workers.
}
#pragma warning restore RECS0022 // A catch clause that catches System.Exception and has an empty body
}
}
public async ValueTask DisposeAsync()
{
await CompleteAsync();
}
}

61
backend/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs

@ -5,7 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks.Dataflow; using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.Tasks;
@ -55,28 +56,60 @@ public static class TaskExtensions
} }
} }
#pragma warning disable RECS0165 // Asynchronous methods should return a Task instead of void public static async IAsyncEnumerable<T> Buffered<T>(this IAsyncEnumerable<T> source, int capacity,
public static async void BidirectionalLinkTo<T>(this ISourceBlock<T> source, ITargetBlock<T> target) [EnumeratorCancellation] CancellationToken ct = default)
#pragma warning restore RECS0165 // Asynchronous methods should return a Task instead of void
{ {
source.LinkTo(target, new DataflowLinkOptions var bufferChannel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity)
{ {
PropagateCompletion = true SingleWriter = true,
SingleReader = true,
}); });
using var bufferCompletion = new CancellationTokenSource();
var producer = Task.Run(async () =>
{
try
{
await foreach (var item in source.WithCancellation(bufferCompletion.Token).ConfigureAwait(false))
{
await bufferChannel.Writer.WriteAsync(item, bufferCompletion.Token).ConfigureAwait(false);
}
}
catch (ChannelClosedException)
{
// Ignore
}
catch (OperationCanceledException)
{
// Ignore
}
finally
{
bufferChannel.Writer.TryComplete();
}
}, bufferCompletion.Token);
try try
{ {
await target.Completion.ConfigureAwait(false); await foreach (T item in bufferChannel.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
yield return item;
ct.ThrowIfCancellationRequested();
}
await producer.ConfigureAwait(false); // Propagate possible source error
} }
catch finally
{ {
// We do not want to change the stacktrace of the exception. if (!producer.IsCompleted)
return; {
} bufferCompletion.Cancel();
bufferChannel.Writer.TryComplete();
if (target.Completion.IsFaulted && target.Completion.Exception != null) await Task.WhenAny(producer).ConfigureAwait(false);
{ }
source.Fault(target.Completion.Exception.Flatten());
} }
} }
} }

12
backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeader.cs

@ -22,7 +22,7 @@ public class AcceptHeader
public sealed class UnpublishedAttribute : BaseAttribute public sealed class UnpublishedAttribute : BaseAttribute
{ {
public UnpublishedAttribute() public UnpublishedAttribute()
: base(ContentHeaders.Unpublished, "Return unpublished content items.", JsonObjectType.Boolean) : base(ContentHeaders.KeyUnpublished, "Return unpublished content items.", JsonObjectType.Boolean)
{ {
} }
} }
@ -30,7 +30,7 @@ public class AcceptHeader
public sealed class FieldsAttribute : BaseAttribute public sealed class FieldsAttribute : BaseAttribute
{ {
public FieldsAttribute() public FieldsAttribute()
: base(ContentHeaders.Fields, "The list of content fields (comma-separated).", JsonObjectType.String) : base(ContentHeaders.KeyFields, "The list of content fields (comma-separated).", JsonObjectType.String)
{ {
} }
} }
@ -38,7 +38,7 @@ public class AcceptHeader
public sealed class FlattenAttribute : BaseAttribute public sealed class FlattenAttribute : BaseAttribute
{ {
public FlattenAttribute() public FlattenAttribute()
: base(ContentHeaders.Flatten, "Provide the data as flat object.", JsonObjectType.Boolean) : base(ContentHeaders.KeyFlatten, "Provide the data as flat object.", JsonObjectType.Boolean)
{ {
} }
} }
@ -46,7 +46,7 @@ public class AcceptHeader
public sealed class LanguagesAttribute : BaseAttribute public sealed class LanguagesAttribute : BaseAttribute
{ {
public LanguagesAttribute() public LanguagesAttribute()
: base(ContentHeaders.Languages, "The list of languages to resolve (comma-separated).") : base(ContentHeaders.KeyLanguages, "The list of languages to resolve (comma-separated).")
{ {
} }
} }
@ -54,7 +54,7 @@ public class AcceptHeader
public sealed class NoTotalAttribute : BaseAttribute public sealed class NoTotalAttribute : BaseAttribute
{ {
public NoTotalAttribute() public NoTotalAttribute()
: base(ContextHeaders.NoTotal, "Do not return the total amount.", JsonObjectType.Boolean) : base(ContextHeaders.KeyNoTotal, "Do not return the total amount.", JsonObjectType.Boolean)
{ {
} }
} }
@ -62,7 +62,7 @@ public class AcceptHeader
public sealed class NoSlowTotalAttribute : BaseAttribute public sealed class NoSlowTotalAttribute : BaseAttribute
{ {
public NoSlowTotalAttribute() public NoSlowTotalAttribute()
: base(ContextHeaders.NoSlowTotal, "Do not return the total amount, if it would be slow.", JsonObjectType.Boolean) : base(ContextHeaders.KeyNoSlowTotal, "Do not return the total amount, if it would be slow.", JsonObjectType.Boolean)
{ {
} }
} }

4
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -62,7 +62,7 @@ public sealed class AssetContentController : ApiController
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetAssetContentBySlug(string app, string idOrSlug, AssetContentQueryDto request, string? more = null) public async Task<IActionResult> GetAssetContentBySlug(string app, string idOrSlug, AssetContentQueryDto request, string? more = null)
{ {
var requestContext = Context.Clone(b => b.WithoutAssetEnrichment()); var requestContext = Context.Clone(b => b.WithNoAssetEnrichment());
var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(idOrSlug), ct: HttpContext.RequestAborted); var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(idOrSlug), ct: HttpContext.RequestAborted);
@ -90,7 +90,7 @@ public sealed class AssetContentController : ApiController
[Obsolete("Use overload with app name")] [Obsolete("Use overload with app name")]
public async Task<IActionResult> GetAssetContent(DomainId id, AssetContentQueryDto request) public async Task<IActionResult> GetAssetContent(DomainId id, AssetContentQueryDto request)
{ {
var requestContext = Context.Clone(b => b.WithoutAssetEnrichment()); var requestContext = Context.Clone(b => b.WithNoAssetEnrichment());
var asset = await assetQuery.FindGlobalAsync(requestContext, id, HttpContext.RequestAborted); var asset = await assetQuery.FindGlobalAsync(requestContext, id, HttpContext.RequestAborted);

2
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -121,7 +121,7 @@ public sealed class ContentDto : Resource
SchemaName = content.SchemaId.Name SchemaName = content.SchemaId.Name
}); });
if (resources.Context.ShouldFlatten()) if (resources.Context.Flatten())
{ {
response.Data = content.Data.ToFlatten(); response.Data = content.Data.ToFlatten();
} }

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetsBulkUpdateCommandMiddlewareTests.cs

@ -22,9 +22,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
public AssetsBulkUpdateCommandMiddlewareTests() public AssetsBulkUpdateCommandMiddlewareTests()
{ {
var log = A.Fake<ILogger<AssetsBulkUpdateCommandMiddleware>>(); sut = new AssetsBulkUpdateCommandMiddleware(contextProvider);
sut = new AssetsBulkUpdateCommandMiddleware(contextProvider, log);
} }
[Fact] [Fact]
@ -61,7 +59,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual); Assert.Single(actual);
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(A<AnnotateAsset>.That.Matches(x => x.AssetId == id && x.FileName == "file"), CancellationToken)) A.CallTo(() => commandBus.PublishAsync(A<AnnotateAsset>.That.Matches(x => x.AssetId == id && x.FileName == "file"), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -97,7 +95,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual); Assert.Single(actual);
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(A<MoveAsset>.That.Matches(x => x.AssetId == id), CancellationToken)) A.CallTo(() => commandBus.PublishAsync(A<MoveAsset>.That.Matches(x => x.AssetId == id), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -134,7 +132,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<DeleteAsset>.That.Matches(x => x.AssetId == id), CancellationToken)) A<DeleteAsset>.That.Matches(x => x.AssetId == id), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs

@ -29,7 +29,7 @@ public class AssetQueryParserTests : GivenContext
[Fact] [Fact]
public async Task Should_skip_total_if_set_in_context() public async Task Should_skip_total_if_set_in_context()
{ {
var q = await sut.ParseAsync(ApiContext.Clone(b => b.WithoutTotal()), Q.Empty, CancellationToken); var q = await sut.ParseAsync(ApiContext.Clone(b => b.WithNoTotal()), Q.Empty, CancellationToken);
Assert.True(q.NoTotal); Assert.True(q.NoTotal);
} }

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/CalculateTokensTests.cs

@ -28,7 +28,7 @@ public class CalculateTokensTests : GivenContext
{ {
var asset = CreateAsset(); var asset = CreateAsset();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutAssetEnrichment()), Enumerable.Repeat(asset, 1), default); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoAssetEnrichment()), Enumerable.Repeat(asset, 1), default);
Assert.Null(asset.EditToken); Assert.Null(asset.EditToken);
} }

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ConvertTagsTests.cs

@ -38,7 +38,7 @@ public class ConvertTagsTests : GivenContext
{ {
var asset = new AssetEntity(); var asset = new AssetEntity();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutAssetEnrichment()), Enumerable.Repeat(asset, 1), CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoAssetEnrichment()), Enumerable.Repeat(asset, 1), CancellationToken);
Assert.Null(asset.TagNames); Assert.Null(asset.TagNames);
} }

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs

@ -63,7 +63,7 @@ public class EnrichForCachingTests : GivenContext
[Fact] [Fact]
public async Task Should_not_add_cache_headers_if_disabled() public async Task Should_not_add_cache_headers_if_disabled()
{ {
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoCacheKeys()), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._)) A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -74,7 +74,7 @@ public class EnrichForCachingTests : GivenContext
{ {
var asset = CreateAsset(); var asset = CreateAsset();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), Enumerable.Repeat(asset, 1), CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoCacheKeys()), Enumerable.Repeat(asset, 1), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._)) A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened(); .MustNotHaveHappened();

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichWithMetadataTextTests.cs

@ -32,7 +32,7 @@ public class EnrichWithMetadataTextTests : GivenContext
{ {
var asset = new AssetEntity(); var asset = new AssetEntity();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutAssetEnrichment()), Enumerable.Repeat(asset, 1), CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoAssetEnrichment()), Enumerable.Repeat(asset, 1), CancellationToken);
A.CallTo(() => assetMetadataSource1.Format(A<IAssetEntity>._)) A.CallTo(() => assetMetadataSource1.Format(A<IAssetEntity>._))
.MustNotHaveHappened(); .MustNotHaveHappened();

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs

@ -137,7 +137,7 @@ public class ScriptAssetTests : GivenContext
private Context ContextWithNoScript() private Context ContextWithNoScript()
{ {
var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id; var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id;
var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithoutScripting()); var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithNoScripting());
return contextInstance; return contextInstance;
} }

67
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs

@ -30,9 +30,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
public ContentsBulkUpdateCommandMiddlewareTests() public ContentsBulkUpdateCommandMiddlewareTests()
{ {
var log = A.Fake<ILogger<ContentsBulkUpdateCommandMiddleware>>(); sut = new ContentsBulkUpdateCommandMiddleware(contentQuery, contextProvider);
sut = new ContentsBulkUpdateCommandMiddleware(contentQuery, contextProvider, log);
} }
[Fact] [Fact]
@ -82,10 +80,11 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
A.CallTo(() => contentQuery.QueryAsync( A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x => A<Context>.That.Matches(x =>
x.ShouldSkipCleanup() && x.NoCleanup() &&
x.ShouldSkipContentEnrichment() && x.NoEnrichment() &&
x.ShouldSkipTotal()), x.NoTotal()),
schemaId.Name, A<Q>.That.Matches(x => x.JsonQuery == query), CancellationToken)) schemaId.Id.ToString(),
A<Q>.That.Matches(x => x.JsonQuery == query), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id))); .Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id)));
var command = BulkCommand(BulkUpdateContentType.ChangeStatus, new BulkUpdateJob { Query = query }); var command = BulkCommand(BulkUpdateContentType.ChangeStatus, new BulkUpdateJob { Query = query });
@ -108,10 +107,12 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
A.CallTo(() => contentQuery.QueryAsync( A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x => A<Context>.That.Matches(x =>
x.ShouldSkipCleanup() && x.NoCleanup() &&
x.ShouldSkipContentEnrichment() && x.NoEnrichment() &&
x.ShouldSkipTotal()), x.NoTotal()),
schemaId.Name, A<Q>.That.Matches(x => x.JsonQuery == query), CancellationToken)) schemaId.Id.ToString(),
A<Q>.That.Matches(x => x.JsonQuery == query),
A<CancellationToken>._))
.Returns(ResultList.CreateFrom(1, CreateContent(id))); .Returns(ResultList.CreateFrom(1, CreateContent(id)));
var command = BulkCommand(BulkUpdateContentType.Upsert, new BulkUpdateJob { Query = query, Data = data }); var command = BulkCommand(BulkUpdateContentType.Upsert, new BulkUpdateJob { Query = query, Data = data });
@ -122,7 +123,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -138,10 +139,12 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
A.CallTo(() => contentQuery.QueryAsync( A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x => A<Context>.That.Matches(x =>
x.ShouldSkipCleanup() && x.NoCleanup() &&
x.ShouldSkipContentEnrichment() && x.NoEnrichment() &&
x.ShouldSkipTotal()), x.NoTotal()),
schemaId.Name, A<Q>.That.Matches(x => x.JsonQuery == query), CancellationToken)) schemaId.Id.ToString(),
A<Q>.That.Matches(x => x.JsonQuery == query),
A<CancellationToken>._))
.Returns(ResultList.CreateFrom(2, .Returns(ResultList.CreateFrom(2,
CreateContent(id1), CreateContent(id1),
CreateContent(id2))); CreateContent(id2)));
@ -157,11 +160,11 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id2 && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id2 && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id1), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id1), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id2), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id2), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -180,7 +183,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId != default), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId != default), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -199,7 +202,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId != default), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId != default), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -218,7 +221,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -237,7 +240,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id), CancellationToken)) A<UpsertContent>.That.Matches(x => x.Data == data && x.ContentId == id), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -256,7 +259,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<CreateContent>.That.Matches(x => x.ContentId == id && x.Data == data), CancellationToken)) A<CreateContent>.That.Matches(x => x.ContentId == id && x.Data == data), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -293,7 +296,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<UpdateContent>.That.Matches(x => x.ContentId == id && x.Data == data), CancellationToken)) A<UpdateContent>.That.Matches(x => x.ContentId == id && x.Data == data), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -330,7 +333,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<PatchContent>.That.Matches(x => x.ContentId == id && x.Data == data), CancellationToken)) A<PatchContent>.That.Matches(x => x.ContentId == id && x.Data == data), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -367,7 +370,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<ChangeContentStatus>.That.Matches(x => x.ContentId == id && x.DueTime == null), CancellationToken)) A<ChangeContentStatus>.That.Matches(x => x.ContentId == id && x.DueTime == null), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -386,7 +389,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<ChangeContentStatus>.That.Matches(x => x.ContentId == id && x.DueTime == time), CancellationToken)) A<ChangeContentStatus>.That.Matches(x => x.ContentId == id && x.DueTime == time), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -423,7 +426,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<ValidateContent>.That.Matches(x => x.ContentId == id), CancellationToken)) A<ValidateContent>.That.Matches(x => x.ContentId == id), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -460,7 +463,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.ContentId == id), CancellationToken)) A<DeleteContent>.That.Matches(x => x.ContentId == id), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -487,7 +490,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
{ {
SetupContext(PermissionIds.AppContentsDeleteOwn); SetupContext(PermissionIds.AppContentsDeleteOwn);
A.CallTo(() => contentQuery.GetSchemaOrThrowAsync(A<Context>._, schemaCustomId.Name, CancellationToken)) A.CallTo(() => contentQuery.GetSchemaOrThrowAsync(A<Context>._, schemaCustomId.Name, A<CancellationToken>._))
.Returns(Mocks.Schema(AppId, schemaCustomId)); .Returns(Mocks.Schema(AppId, schemaCustomId));
var (id, _, _) = CreateTestData(false); var (id, _, _) = CreateTestData(false);
@ -500,7 +503,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), CancellationToken)) A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -522,7 +525,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception is DomainObjectNotFoundException); Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception is DomainObjectNotFoundException);
A.CallTo(() => commandBus.PublishAsync( A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), CancellationToken)) A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -201,8 +201,8 @@ public abstract class GraphQLTestBase : IClassFixture<TranslationsFixture>
{ {
return A<Context>.That.Matches(x => return A<Context>.That.Matches(x =>
x.App == TestApp.Default && x.App == TestApp.Default &&
x.ShouldSkipCleanup() && x.NoCleanup() &&
x.ShouldSkipAssetEnrichment() && x.NoAssetEnrichment() &&
x.UserPrincipal == requestContext.UserPrincipal); x.UserPrincipal == requestContext.UserPrincipal);
} }
@ -210,8 +210,8 @@ public abstract class GraphQLTestBase : IClassFixture<TranslationsFixture>
{ {
return A<Context>.That.Matches(x => return A<Context>.That.Matches(x =>
x.App == TestApp.Default && x.App == TestApp.Default &&
x.ShouldSkipCleanup() && x.NoCleanup() &&
x.ShouldSkipContentEnrichment() && x.NoEnrichment() &&
x.UserPrincipal == requestContext.UserPrincipal); x.UserPrincipal == requestContext.UserPrincipal);
} }
} }

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

@ -44,7 +44,7 @@ public class ContentQueryParserTests : GivenContext
[Fact] [Fact]
public async Task Should_skip_total_if_set_in_context() public async Task Should_skip_total_if_set_in_context()
{ {
var q = await sut.ParseAsync(ApiContext.Clone(b => b.WithoutTotal()), Q.Empty, ct: CancellationToken); var q = await sut.ParseAsync(ApiContext.Clone(b => b.WithNoTotal()), Q.Empty, ct: CancellationToken);
Assert.True(q.NoTotal); Assert.True(q.NoTotal);
} }

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs

@ -67,7 +67,7 @@ public class EnrichForCachingTests : GivenContext
[Fact] [Fact]
public async Task Should_not_add_cache_headers_if_disabled() public async Task Should_not_add_cache_headers_if_disabled()
{ {
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoCacheKeys()), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._)) A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -78,7 +78,7 @@ public class EnrichForCachingTests : GivenContext
{ {
var content = CreateContent(); var content = CreateContent();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), Enumerable.Repeat(content, 1), SchemaProvider(), CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithNoCacheKeys()), Enumerable.Repeat(content, 1), SchemaProvider(), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._)) A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened(); .MustNotHaveHappened();

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs

@ -82,7 +82,7 @@ public class ResolveAssetsTests : GivenContext
}; };
A.CallTo(() => assetQuery.QueryAsync( A.CallTo(() => assetQuery.QueryAsync(
A<Context>.That.Matches(x => x.ShouldSkipAssetEnrichment() && x.ShouldSkipTotal()), null, A<Q>.That.HasIds(doc1.Id, doc2.Id), CancellationToken)) A<Context>.That.Matches(x => x.NoAssetEnrichment() && x.NoTotal()), null, A<Q>.That.HasIds(doc1.Id, doc2.Id), CancellationToken))
.Returns(ResultList.CreateFrom(4, doc1, doc2)); .Returns(ResultList.CreateFrom(4, doc1, doc2));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken); await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -114,7 +114,7 @@ public class ResolveAssetsTests : GivenContext
}; };
A.CallTo(() => assetQuery.QueryAsync( A.CallTo(() => assetQuery.QueryAsync(
A<Context>.That.Matches(x => x.ShouldSkipAssetEnrichment() && x.ShouldSkipTotal()), null, A<Q>.That.HasIds(doc1.Id, doc2.Id, img1.Id, img2.Id), CancellationToken)) A<Context>.That.Matches(x => x.NoAssetEnrichment() && x.NoTotal()), null, A<Q>.That.HasIds(doc1.Id, doc2.Id, img1.Id, img2.Id), CancellationToken))
.Returns(ResultList.CreateFrom(4, img1, img2, doc1, doc2)); .Returns(ResultList.CreateFrom(4, img1, img2, doc1, doc2));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken); await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -164,7 +164,7 @@ public class ResolveAssetsTests : GivenContext
CreateContent(new[] { DomainId.NewGuid() }, Array.Empty<DomainId>()) CreateContent(new[] { DomainId.NewGuid() }, Array.Empty<DomainId>())
}; };
await sut.EnrichAsync(FrontendContext.Clone(b => b.WithoutContentEnrichment(true)), contents, schemaProvider, CancellationToken); await sut.EnrichAsync(FrontendContext.Clone(b => b.WithNoEnrichment(true)), contents, schemaProvider, CancellationToken);
Assert.Null(contents[0].ReferenceData); Assert.Null(contents[0].ReferenceData);
@ -204,7 +204,7 @@ public class ResolveAssetsTests : GivenContext
Assert.NotNull(contents[0].ReferenceData); Assert.NotNull(contents[0].ReferenceData);
A.CallTo(() => assetQuery.QueryAsync( A.CallTo(() => assetQuery.QueryAsync(
A<Context>.That.Matches(x => x.ShouldSkipAssetEnrichment() && x.ShouldSkipTotal()), null, A<Q>.That.HasIds(id1), A<CancellationToken>._)) A<Context>.That.Matches(x => x.NoAssetEnrichment() && x.NoTotal()), null, A<Q>.That.HasIds(id1), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs

@ -95,7 +95,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
}; };
A.CallTo(() => contentQuery.QueryAsync( A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x => x.ShouldSkipContentEnrichment() && x.ShouldSkipTotal()), A<Q>.That.HasIds(ref1_1.Id, ref1_2.Id, ref2_1.Id, ref2_2.Id), A<CancellationToken>._)) A<Context>.That.Matches(x => x.NoEnrichment() && x.NoTotal()), A<Q>.That.HasIds(ref1_1.Id, ref1_2.Id, ref2_1.Id, ref2_2.Id), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2)); .Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, default); await sut.EnrichAsync(FrontendContext, contents, schemaProvider, default);
@ -134,7 +134,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
}; };
A.CallTo(() => contentQuery.QueryAsync( A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x => x.ShouldSkipContentEnrichment() && x.ShouldSkipTotal()), A<Q>.That.HasIds(ref1_1.Id, ref1_2.Id, ref2_1.Id, ref2_2.Id), CancellationToken)) A<Context>.That.Matches(x => x.NoEnrichment() && x.NoTotal()), A<Q>.That.HasIds(ref1_1.Id, ref1_2.Id, ref2_1.Id, ref2_2.Id), CancellationToken))
.Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2)); .Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken); await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -187,7 +187,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
}; };
A.CallTo(() => contentQuery.QueryAsync( A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x => x.ShouldSkipContentEnrichment() && x.ShouldSkipTotal()), A<Q>.That.HasIds(ref1_1.Id, ref1_2.Id, ref2_1.Id, ref2_2.Id), CancellationToken)) A<Context>.That.Matches(x => x.NoEnrichment() && x.NoTotal()), A<Q>.That.HasIds(ref1_1.Id, ref1_2.Id, ref2_1.Id, ref2_2.Id), CancellationToken))
.Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2)); .Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken); await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -249,7 +249,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
CreateContent(new[] { DomainId.NewGuid() }, Array.Empty<DomainId>()) CreateContent(new[] { DomainId.NewGuid() }, Array.Empty<DomainId>())
}; };
await sut.EnrichAsync(FrontendContext.Clone(b => b.WithoutContentEnrichment(true)), contents, schemaProvider, CancellationToken); await sut.EnrichAsync(FrontendContext.Clone(b => b.WithNoEnrichment(true)), contents, schemaProvider, CancellationToken);
Assert.Null(contents[0].ReferenceData); Assert.Null(contents[0].ReferenceData);

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

@ -181,7 +181,7 @@ public class ScriptContentTests : GivenContext
private Context ContextWithNoScript() private Context ContextWithNoScript()
{ {
var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id; var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id;
var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithoutScripting()); var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithNoScripting());
return contextInstance; return contextInstance;
} }

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerWorkerTests.cs

@ -63,7 +63,7 @@ public class RuleDequeuerWorkerTests
A.CallTo(() => ruleService.InvokeAsync(A<string>._, A<string>._, default)) A.CallTo(() => ruleService.InvokeAsync(A<string>._, A<string>._, default))
.Throws(new InvalidOperationException()); .Throws(new InvalidOperationException());
await sut.HandleAsync(@event); await sut.HandleAsync(@event, default);
A.CallTo(log).Where(x => x.Method.Name == "Log") A.CallTo(log).Where(x => x.Method.Name == "Log")
.MustHaveHappened(); .MustHaveHappened();
@ -86,8 +86,8 @@ public class RuleDequeuerWorkerTests
}); });
await Task.WhenAll( await Task.WhenAll(
sut.HandleAsync(event1), sut.HandleAsync(event1, default),
sut.HandleAsync(event2)); sut.HandleAsync(event2, default));
A.CallTo(() => ruleService.InvokeAsync(A<string>._, A<string>._, default)) A.CallTo(() => ruleService.InvokeAsync(A<string>._, A<string>._, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
@ -115,7 +115,7 @@ public class RuleDequeuerWorkerTests
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
await sut.HandleAsync(@event); await sut.HandleAsync(@event, default);
if (actual == RuleResult.Failed) if (actual == RuleResult.Failed)
{ {

32
backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs

@ -332,4 +332,36 @@ public class CollectionExtensionsTests
Assert.Equal(source, targetItems); Assert.Equal(source, targetItems);
} }
[Fact]
public void Should_batch()
{
var source = new[] { 1, 2, 3, 4, 5 };
var actual = source.Batch(2).ToArray();
actual.Should().BeEquivalentTo(
new[]
{
new List<int> { 1, 2 },
new List<int> { 3, 4 },
new List<int> { 5 },
});
}
[Fact]
public async Task Should_batch_async()
{
var source = new[] { 1, 2, 3, 4, 5 };
var actual = await source.ToAsyncEnumerable().Batch(2).ToArrayAsync();
actual.Should().BeEquivalentTo(
new[]
{
new List<int> { 1, 2 },
new List<int> { 3, 4 },
new List<int> { 5 },
});
}
} }

55
backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleMapperTests.cs

@ -11,6 +11,10 @@ namespace Squidex.Infrastructure.Reflection;
public class SimpleMapperTests public class SimpleMapperTests
{ {
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
public record struct ValueType(int Value);
#pragma warning restore SA1313 // Parameter names should begin with lower-case letter
public class Class1Base<T1> public class Class1Base<T1>
{ {
public T1 P1 { get; set; } public T1 P1 { get; set; }
@ -91,7 +95,6 @@ public class SimpleMapperTests
{ {
var obj1 = new Class1<long, long> var obj1 = new Class1<long, long>
{ {
P1 = 6,
P2 = 8 P2 = 8
}; };
var obj2 = SimpleMapper.Map(obj1, new Class2<int, int>()); var obj2 = SimpleMapper.Map(obj1, new Class2<int, int>());
@ -101,11 +104,10 @@ public class SimpleMapperTests
} }
[Fact] [Fact]
public void Should_map_from_nullable() public void Should_map_from_convertible_nullable()
{ {
var obj1 = new Class1<long?, long?> var obj1 = new Class1<long?, long?>
{ {
P1 = 6,
P2 = 8 P2 = 8
}; };
var obj2 = SimpleMapper.Map(obj1, new Class2<long, long>()); var obj2 = SimpleMapper.Map(obj1, new Class2<long, long>());
@ -115,11 +117,23 @@ public class SimpleMapperTests
} }
[Fact] [Fact]
public void Should_map_to_nullable() public void Should_map_from_nullable()
{
var obj1 = new Class1<ValueType?, ValueType?>
{
P2 = new ValueType(8)
};
var obj2 = SimpleMapper.Map(obj1, new Class2<ValueType, ValueType>());
Assert.Equal(new ValueType(8), obj2.P2);
Assert.Equal(new ValueType(0), obj2.P3);
}
[Fact]
public void Should_map_to_convertible_nullable()
{ {
var obj1 = new Class1<long, long> var obj1 = new Class1<long, long>
{ {
P1 = 6,
P2 = 8 P2 = 8
}; };
var obj2 = SimpleMapper.Map(obj1, new Class2<long?, long?>()); var obj2 = SimpleMapper.Map(obj1, new Class2<long?, long?>());
@ -128,12 +142,24 @@ public class SimpleMapperTests
Assert.Null(obj2.P3); Assert.Null(obj2.P3);
} }
[Fact]
public void Should_map_to_nullable()
{
var obj1 = new Class1<ValueType, ValueType>
{
P2 = new ValueType(8)
};
var obj2 = SimpleMapper.Map(obj1, new Class2<ValueType?, ValueType?>());
Assert.Equal(new ValueType(8), obj2.P2);
Assert.Null(obj2.P3);
}
[Fact] [Fact]
public void Should_map_if_convertible_is_null() public void Should_map_if_convertible_is_null()
{ {
var obj1 = new Class1<int?, int?> var obj1 = new Class1<int?, int?>
{ {
P1 = null,
P2 = null P2 = null
}; };
var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>()); var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>());
@ -147,7 +173,6 @@ public class SimpleMapperTests
{ {
var obj1 = new Class1<RefToken, RefToken> var obj1 = new Class1<RefToken, RefToken>
{ {
P1 = RefToken.User("1"),
P2 = RefToken.User("2") P2 = RefToken.User("2")
}; };
var obj2 = SimpleMapper.Map(obj1, new Class2<string, string>()); var obj2 = SimpleMapper.Map(obj1, new Class2<string, string>());
@ -161,7 +186,6 @@ public class SimpleMapperTests
{ {
var obj1 = new Class1<long, long> var obj1 = new Class1<long, long>
{ {
P1 = long.MaxValue,
P2 = long.MaxValue P2 = long.MaxValue
}; };
var obj2 = SimpleMapper.Map(obj1, new Class2<int, int>()); var obj2 = SimpleMapper.Map(obj1, new Class2<int, int>());
@ -171,19 +195,22 @@ public class SimpleMapperTests
} }
[Fact] [Fact]
public void Should_ignore_write_only() public void Should_ignore_read_only()
{ {
var obj1 = new Writeonly<int>(); var obj1 = new Class1<int, int>
var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>()); {
P1 = 10
};
var obj2 = SimpleMapper.Map(obj1, new Readonly<int>());
Assert.Equal(0, obj2.P1); Assert.Equal(0, obj2.P1);
} }
[Fact] [Fact]
public void Should_ignore_read_only() public void Should_ignore_write_only()
{ {
var obj1 = new Class1<int, int> { P1 = 10 }; var obj1 = new Writeonly<int>();
var obj2 = SimpleMapper.Map(obj1, new Readonly<int>()); var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>());
Assert.Equal(0, obj2.P1); Assert.Equal(0, obj2.P1);
} }

23
backend/tests/Squidex.Infrastructure.Tests/Tasks/PartitionedActionBlockTests.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks.Dataflow;
namespace Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.Tasks;
public class PartitionedActionBlockTests public class PartitionedActionBlockTests
@ -23,31 +21,24 @@ public class PartitionedActionBlockTests
lists[i] = new List<int>(); lists[i] = new List<int>();
} }
var block = new PartitionedActionBlock<(int P, int V)>(x => var scheduler = new PartitionedScheduler<(int Partition, int Value)>((item, ct) =>
{ {
Random.Shared.Next(10); Random.Shared.Next(10);
lists[x.P].Add(x.V); lists[item.Partition].Add(item.Value);
return Task.CompletedTask; return Task.CompletedTask;
}, x => x.P, new ExecutionDataflowBlockOptions }, 32, 10000);
{
MaxDegreeOfParallelism = 100,
MaxMessagesPerTask = 1,
BoundedCapacity = 100
});
for (var i = 0; i < Partitions; i++) for (var partition = 0; partition < Partitions; partition++)
{ {
for (var j = 0; j < 10; j++) for (var value = 0; value < 10; value++)
{ {
await block.SendAsync((i, j)); await scheduler.ScheduleAsync(partition, (partition, value));
} }
} }
block.Complete(); await scheduler.CompleteAsync();
await block.Completion;
foreach (var list in lists) foreach (var list in lists)
{ {

Loading…
Cancel
Save