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,
};
if (job.Event is EnrichedUserEventBase userEvent)
if (job.Event is EnrichedUserEventBase)
{
vars.User = AllPrinicpal();
}

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

@ -6,7 +6,6 @@
// ==========================================================================
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks.Dataflow;
using MongoDB.Bson;
using MongoDB.Driver;
using Squidex.Infrastructure;
@ -28,104 +27,79 @@ public sealed class AddAppIdToEventStream : MongoBase<BsonDocument>, IMigration
public async Task UpdateAsync(
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.
var collectionV1 = database.GetCollection<BsonDocument>("Events");
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 (TryGetAppId(document, out var appId))
if (!eventStream.StartsWith("app-", StringComparison.OrdinalIgnoreCase))
{
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)
{
indexOfId = indexOfOldId + 2;
}
var domainType = eventStream[..indexOfType];
var domainId = eventStream[indexOfId..];
if (indexOfOldId > 0)
{
indexOfId = indexOfOldId + 2;
}
var newDomainId = DomainId.Combine(DomainId.Create(appId), DomainId.Create(domainId)).ToString();
var newStreamName = $"{domainType}-{newDomainId}";
var domainType = eventStream[..indexOfType];
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)
{
var metadata = @event["Metadata"].AsBsonDocument;
metadata["AggregateId"] = newDomainId;
}
}
document["EventStream"] = newStreamName;
foreach (var @event in document["Events"].AsBsonArray)
{
var metadata = @event["Metadata"].AsBsonDocument;
metadata.Remove("AppId");
metadata["AggregateId"] = newDomainId;
}
}
var filter = Builders<BsonDocument>.Filter.Eq("_id", document["_id"].AsString);
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
foreach (var @event in document["Events"].AsBsonArray)
{
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 (!await batchBlock.SendAsync(commit, ct))
if (writes.Count > 0)
{
break;
await collectionV2.BulkWriteAsync(writes, BulkUnordered, ct);
}
}
batchBlock.Complete();
await actionBlock.Completion;
});
}
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.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
using MongoDB.Bson;
using MongoDB.Driver;
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,
CancellationToken ct)
{
const int SizeOfBatch = 1000;
const int SizeOfQueue = 10;
string collectionNameV2;
collectionNameV2 = $"{collectionNameV1}2";
@ -91,80 +87,46 @@ public sealed class ConvertDocumentIds : MongoBase<BsonDocument>, IMigration
await collectionV2.DeleteManyAsync(FindAll, ct);
var batchBlock = new BatchBlock<BsonDocument>(SizeOfBatch, new GroupingDataflowBlockOptions
{
BoundedCapacity = SizeOfQueue * SizeOfBatch
});
// 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);
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 =>
{
try
foreach (var document in batch)
{
var writes = new List<WriteModel<BsonDocument>>();
foreach (var document in batch)
{
var appId = document["_ai"].AsString;
var appId = document["_ai"].AsString;
var documentIdOld = document["_id"].AsString;
var documentIdOld = document["_id"].AsString;
if (documentIdOld.Contains("--", StringComparison.OrdinalIgnoreCase))
{
var index = documentIdOld.LastIndexOf("--", StringComparison.OrdinalIgnoreCase);
if (documentIdOld.Contains("--", 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"] = documentIdNew;
document["id"] = documentIdOld;
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)
{
IsUpsert = true
});
}
if (writes.Count > 0)
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
{
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 (!await batchBlock.SendAsync(document, ct))
if (writes.Count > 0)
{
break;
await collectionV2.BulkWriteAsync(writes, BulkUnordered, ct);
}
}
batchBlock.Complete();
await actionBlock.Completion;
});
}
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 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);
}

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

@ -154,7 +154,7 @@ public sealed class AssetsFluidExtension : IFluidExtension
var requestContext =
Context.Admin(app).Clone(b => b
.WithoutTotal());
.WithNoTotal());
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 =
new Context(user, app).Clone(b => b
.WithoutTotal());
.WithNoTotal());
var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct);
@ -264,7 +264,7 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
var requestContext =
new Context(user, app).Clone(b => b
.WithoutTotal());
.WithNoTotal());
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);
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();
try
{
await assetFileStore.CopyAsync(tempFile, asset.AppId.Id, asset.AssetId, asset.FileVersion, null, ct);
}
catch (AssetAlreadyExistsException) when (context.Command is not UpsertAsset)
await assetFileStore.CopyAsync(tempFile, asset.AppId.Id, asset.AssetId, asset.FileVersion, null, ct);
}
catch (AssetAlreadyExistsException)
{
if (context.Command is not UpsertAsset)
{
throw;
}
}
}
if (payload is not IEnrichedAssetEntity)
{
payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, ct);
}
if (payload is not IEnrichedAssetEntity)
{
payload = await assetEnricher.EnrichAsync(asset, contextProvider.Context, ct);
}
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.
// ==========================================================================
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.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared;
#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
{
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(
ICommandBus Bus,
BulkUpdateJob BulkJob,
BulkUpdateAssets Bulk,
int JobIndex,
BulkUpdateJob CommandJob,
BulkUpdateAssets Command,
ConcurrentBag<BulkUpdateResultItem> Results,
CancellationToken Aborted);
AssetCommand? Command)
{
public BulkUpdateResultItem? Result { get; private set; }
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.log = log;
}
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is 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
if (context.Command is not BulkUpdateAssets bulkUpdates)
{
await next(context, ct);
return;
}
}
private async Task ExecuteCommandAsync(BulkTaskCommand bulkCommand)
{
var (task, id, command, ct) = bulkCommand;
try
if (bulkUpdates.Jobs == null || bulkUpdates.Jobs.Length == 0)
{
await task.Bus.PublishAsync(command, ct);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex));
context.Complete(new BulkUpdateResult());
return;
}
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}.",
task.JobIndex,
task.CommandJob.Type);
foreach (var task in group)
{
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;
try
if (task.Result != null || task.Command == null)
{
var command = CreateCommandCore(task);
// Set the asset id here in case we have another way to resolve ids.
command.AssetId = id;
return;
}
return new BulkTaskCommand(task, id, command, task.Aborted);
try
{
await commandBus.PublishAsync(task.Command, ct);
task.SetResult();
}
catch (Exception ex)
{
log.LogError(ex, "Failed to execute asset bulk job with index {index} of type {type}.",
task.JobIndex,
task.CommandJob.Type);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex, ex));
return null;
task.SetResult(ex);
}
}
private AssetCommand CreateCommandCore(BulkTask task)
private BulkTask CreateTask(
BulkUpdateJob bulkJob,
BulkUpdateAssets bulk,
int jobIndex)
{
var job = task.CommandJob;
switch (job.Type)
try
{
case BulkUpdateAssetType.Annotate:
{
var command = new AnnotateAsset();
EnrichAndCheckPermission(task, command, PermissionIds.AppAssetsUpdate);
return command;
}
case BulkUpdateAssetType.Move:
{
var command = new MoveAsset();
EnrichAndCheckPermission(task, command, PermissionIds.AppAssetsUpdate);
return command;
}
switch (bulkJob.Type)
{
case BulkUpdateAssetType.Annotate:
return CreateTask<AnnotateAsset>(bulkJob, bulk, jobIndex,
PermissionIds.AppAssetsUpdate);
case BulkUpdateAssetType.Delete:
{
var command = new DeleteAsset();
case BulkUpdateAssetType.Move:
return CreateTask<MoveAsset>(bulkJob, bulk, jobIndex,
PermissionIds.AppAssetsUpdate);
EnrichAndCheckPermission(task, command, PermissionIds.AppAssetsDelete);
return command;
}
case BulkUpdateAssetType.Delete:
return CreateTask<DeleteAsset>(bulkJob, bulk, jobIndex,
PermissionIds.AppAssetsDelete);
default:
ThrowHelper.NotSupportedException();
return default!;
default:
return BulkTask.Failed(bulkJob, bulk, jobIndex, new NotSupportedException());
}
}
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))
{
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);
if (context.ShouldSkipTotal())
if (context.NoTotal())
{
q = q.WithoutTotal();
}
else if (context.ShouldSkipSlowTotal())
else if (context.NoSlowTotal())
{
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,
CancellationToken ct)
{
if (context.ShouldSkipAssetEnrichment())
if (context.NoAssetEnrichment())
{
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,
CancellationToken ct)
{
if (context.ShouldSkipAssetEnrichment())
if (context.NoAssetEnrichment())
{
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)
{
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,
CancellationToken ct)
{
if (context.ShouldSkipAssetEnrichment())
if (context.NoAssetEnrichment())
{
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.
var shouldScript =
!context.ShouldSkipScripting() ||
!context.NoScripting() ||
!context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name));
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.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
@ -285,67 +285,55 @@ public sealed partial class RestoreProcessor
private async Task ReadEventsAsync(Run run,
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 writeBlock = new ActionBlock<(string, Envelope<IEvent>)[]>(async batch =>
await Parallel.ForEachAsync(events, new ParallelOptions
{
try
{
var commits = new List<EventCommit>(batch.Length);
foreach (var (stream, @event) in batch)
{
var offset = run.StreamMapper.GetStreamOffset(stream);
commits.Add(EventCommit.Create(stream, offset, @event, eventFormatter));
}
CancellationToken = ct,
// The event store cannot insert events in parallel.
MaxDegreeOfParallelism = 1,
},
async (batch, ct) =>
{
var commits =
batch.Select(item =>
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);
}
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
await LogAsync(run, $"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true);
});
}
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))
{
break;
}
var offset = run.StreamMapper.GetStreamOffset(newStream);
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)
{
if (@event.Payload is AppCreated appCreated)
@ -390,11 +378,11 @@ public sealed partial class RestoreProcessor
{
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,

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.Infrastructure;
using Squidex.Infrastructure.Caching;
using static OpenIddict.Abstractions.OpenIddictConstants;
#pragma warning disable IDE0060 // Remove unused parameter
@ -15,34 +16,32 @@ namespace Squidex.Domain.Apps.Entities.Contents;
public static class ContentHeaders
{
private static readonly char[] Separators = { ',', ';' };
public const string Fields = "X-Fields";
public const string Flatten = "X-Flatten";
public const string Languages = "X-Languages";
public const string NoCleanup = "X-NoCleanup";
public const string NoEnrichment = "X-NoEnrichment";
public const string NoResolveLanguages = "X-NoResolveLanguages";
public const string ResolveFlow = "X-ResolveFlow";
public const string ResolveUrls = "X-ResolveUrls";
public const string Unpublished = "X-Unpublished";
public const string KeyFields = "X-Fields";
public const string KeyFlatten = "X-Flatten";
public const string KeyLanguages = "X-Languages";
public const string KeyNoCleanup = "X-NoCleanup";
public const string KeyNoEnrichment = "X-NoEnrichment";
public const string KeyNoResolveLanguages = "X-NoResolveLanguages";
public const string KeyResolveFlow = "X-ResolveFlow";
public const string KeyResolveUrls = "X-ResolveUrls";
public const string KeyUnpublished = "X-Unpublished";
public static void AddCacheHeaders(this Context context, IRequestCache cache)
{
cache.AddHeader(Fields);
cache.AddHeader(Flatten);
cache.AddHeader(Languages);
cache.AddHeader(NoCleanup);
cache.AddHeader(NoEnrichment);
cache.AddHeader(NoResolveLanguages);
cache.AddHeader(ResolveFlow);
cache.AddHeader(ResolveUrls);
cache.AddHeader(Unpublished);
cache.AddHeader(KeyFields);
cache.AddHeader(KeyFlatten);
cache.AddHeader(KeyLanguages);
cache.AddHeader(KeyNoCleanup);
cache.AddHeader(KeyNoEnrichment);
cache.AddHeader(KeyNoResolveLanguages);
cache.AddHeader(KeyResolveFlow);
cache.AddHeader(KeyResolveUrls);
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)
@ -50,113 +49,93 @@ public static class ContentHeaders
return content.EditingStatus() == Status.Published;
}
public static SearchScope Scope(this Context context)
{
return context.ShouldProvideUnpublished() || context.IsFrontendClient ? SearchScope.All : SearchScope.Published;
}
public static bool ShouldSkipCleanup(this Context context)
public static bool NoCleanup(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)
{
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)
{
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)
{
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 value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToHashSet();
}
return Enumerable.Empty<string>();
return context.AsStrings(KeyResolveUrls);
}
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 value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).ToHashSet();
}
return null;
return context.AsStrings(KeyFields).ToHashSet();
}
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 value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => (Language)x).ToHashSet();
}
return new HashSet<Language>();
return context.AsStrings(KeyLanguages).Select(Language.GetLanguage).ToHashSet();
}
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;
}
// Resolve the app ID once, because we loop over the results.
var appId = context.App.NamedId();
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)
{
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)

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

@ -5,16 +5,11 @@
// 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.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Translations;
using Squidex.Shared;
@ -27,299 +22,239 @@ public sealed class ContentsBulkUpdateCommandMiddleware : ICommandMiddleware
{
private readonly IContentQueryService contentQuery;
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(
ICommandBus Bus,
NamedId<DomainId> SchemaId,
BulkUpdateJob BulkJob,
BulkUpdateContents Bulk,
int JobIndex,
BulkUpdateJob CommandJob,
BulkUpdateContents Command,
ConcurrentBag<BulkUpdateResultItem> Results,
CancellationToken Aborted);
ContentCommand? Command)
{
public BulkUpdateResultItem? Result { get; private set; }
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(
IContentQueryService contentQuery,
IContextProvider contextProvider,
ILogger<ContentsBulkUpdateCommandMiddleware> log)
IContextProvider contextProvider)
{
this.contentQuery = contentQuery;
this.contextProvider = contextProvider;
this.log = log;
}
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is BulkUpdateContents bulkUpdates)
if (context.Command is not BulkUpdateContents bulkUpdates)
{
if (bulkUpdates.Jobs?.Length > 0)
{
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());
}
await next(context, ct);
return;
}
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
{
await task.Bus.PublishAsync(command, ct);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex));
await commandBus.PublishAsync(task.Command, ct);
task.SetResult();
}
catch (Exception ex)
{
log.LogError(ex, "Failed to execute content bulk job with index {index} of type {type}.",
task.JobIndex,
task.CommandJob.Type);
task.Results.Add(new BulkUpdateResultItem(id, task.JobIndex, ex));
task.SetResult(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.
var commands = new List<BulkTaskCommand>();
var tasks = new List<BulkTask>();
try
{
// Check whether another schema is defined for the current job and override the schema id if necessary.
var overridenSchema = task.CommandJob.Schema;
var schemaId = bulk.SchemaId;
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.
task = task with { SchemaId = schema.NamedId() };
schemaId = schema.NamedId();
}
// 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)
{
throw new DomainObjectNotFoundException("undefined");
tasks.Add(BulkTask.Failed(bulkJob, bulk, jobIndex, new DomainObjectNotFoundException("undefined")));
return tasks;
}
foreach (var id in resolvedIds)
{
try
{
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));
}
tasks.Add(CreateTask(id, schemaId, bulkJob, bulk, jobIndex));
}
}
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;
switch (job.Type)
try
{
case BulkUpdateContentType.Create:
{
var command = new CreateContent();
EnrichAndCheckPermission(task, command, 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;
}
switch (bulkJob.Type)
{
case BulkUpdateContentType.Create:
return CreateTask<CreateContent>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsCreate);
case BulkUpdateContentType.Validate:
{
var command = new ValidateContent();
case BulkUpdateContentType.Update:
return CreateTask<UpdateContent>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsUpdateOwn);
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsReadOwn);
return command;
}
case BulkUpdateContentType.Upsert:
return CreateTask<UpsertContent>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsUpsert);
case BulkUpdateContentType.ChangeStatus:
{
var command = new ChangeContentStatus { Status = job.Status ?? Status.Draft };
case BulkUpdateContentType.Patch:
return CreateTask<PatchContent>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsUpdateOwn);
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsChangeStatusOwn);
return command;
}
case BulkUpdateContentType.Validate:
return CreateTask<ValidateContent>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsReadOwn);
case BulkUpdateContentType.Delete:
{
var command = new DeleteContent();
case BulkUpdateContentType.ChangeStatus:
return CreateTask<ChangeContentStatus>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsChangeStatusOwn);
EnrichAndCheckPermission(task, command, PermissionIds.AppContentsDeleteOwn);
return command;
}
case BulkUpdateContentType.Delete:
return CreateTask<DeleteContent>(id, schemaId, bulkJob, bulk, jobIndex,
PermissionIds.AppContentsDeleteOwn);
default:
ThrowHelper.NotSupportedException();
return default!;
default:
return BulkTask.Failed(bulkJob, bulk, jobIndex, new NotSupportedException());
}
}
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);
SimpleMapper.Map(task.CommandJob, command);
if (!contextProvider.Context.Allows(permissionId, command.SchemaId.Name))
if (!contextProvider.Context.Allows(permissionId, schemaId.Name))
{
throw new DomainForbiddenException("Forbidden");
return BulkTask.Failed(bulkJob, bulk, jobIndex, new DomainForbiddenException("Forbidden"));
}
command.SchemaId = task.SchemaId;
command.ExpectedVersion = task.Command.ExpectedVersion;
var command = new T();
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)
{
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 existingResult = await contentQuery.QueryAsync(contextProvider.Context, schema, existingQuery, task.Aborted);
var existingQuery = Q.Empty.WithJsonQuery(bulkJob.Query);
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"));
}
// 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.
if (existingResult.Count == 0 && task.CommandJob.Type == BulkUpdateContentType.Upsert)
if (existingResult.Count == 0 && bulkJob.Type == BulkUpdateContentType.Upsert)
{
return new[] { DomainId.NewGuid() };
}
@ -327,7 +262,7 @@ public sealed class ContentsBulkUpdateCommandMiddleware : ICommandMiddleware
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() };
}

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
{
private const int MaxBatchSize = 5000;
private const int MinBatchSize = 1;
private static readonly EmptyDataLoaderResult<IEnrichedAssetEntity> EmptyAssets = new EmptyDataLoaderResult<IEnrichedAssetEntity>();
private static readonly EmptyDataLoaderResult<IEnrichedContentEntity> EmptyContents = new EmptyDataLoaderResult<IEnrichedContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaders;
private readonly GraphQLOptions options;
private readonly int batchSize;
public override Context Context { get; }
@ -38,11 +41,22 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
this.dataLoaders = dataLoaders;
Context = context.Clone(b => b
.WithoutCleanup()
.WithoutContentEnrichment()
.WithoutAssetEnrichment());
.WithNoCleanup()
.WithNoEnrichment()
.WithNoAssetEnrichment());
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,
@ -121,7 +135,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
var result = await QueryAssetsByIdsAsync(batch, ct);
return result.ToDictionary(x => x.Id);
}, maxBatchSize: options.DataLoaderBatchSize);
}, maxBatchSize: batchSize);
}
private IDataLoader<CacheableId<DomainId>, IEnrichedContentEntity> GetContentsLoader()
@ -132,7 +146,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
var result = await QueryContentsByIdsAsync(batch, null, ct);
return result.ToDictionary(x => x.Id);
}, maxBatchSize: options.DataLoaderBatchSize);
}, maxBatchSize: batchSize);
}
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);
return result.ToDictionary(x => (x.Id, fields));
}, maxBatchSize: options.DataLoaderBatchSize);
}, maxBatchSize: batchSize);
}
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;
});
}
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);
q = q.WithQuery(query);
q = q.WithFields(context.FieldsList());
q = q.WithFields(context.Fields());
if (context.ShouldSkipTotal())
if (context.NoTotal())
{
q = q.WithoutTotal();
}
else if (context.ShouldSkipSlowTotal())
else if (context.NoSlowTotal())
{
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.
context = context.Clone(b => b
.WithoutScripting()
.WithoutCacheKeys()
.WithoutContentEnrichment());
.WithNoScripting()
.WithNoCacheKeys()
.WithNoEnrichment());
// 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);

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,
CancellationToken ct)
{
if (context.ShouldSkipCleanup())
if (context.NoCleanup())
{
return null;
}
@ -136,14 +136,14 @@ public sealed class ConvertData : IContentEnricherStep
converter.Add(
new ResolveLanguages(
context.App.Languages,
context.LanguagesList().ToArray())
context.Languages().ToArray())
{
ResolveFallback = !context.IsFrontendClient && context.ShouldResolveLanguages()
ResolveFallback = !context.IsFrontendClient && !context.NoResolveLanguages()
});
if (!context.IsFrontendClient)
{
var assetUrls = context.AssetUrls().ToList();
var assetUrls = context.ResolveUrls().ToList();
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)
{
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)
{
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
.WithoutAssetEnrichment(true)
.WithoutTotal());
.WithNoAssetEnrichment(true)
.WithNoTotal());
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)
{
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;
}
// Ensure that we reset the fields to not use the field selection from the parent query.
var queryContext = context.Clone(b => b
.WithoutContentEnrichment(true)
.WithoutTotal());
.WithFields(null)
.WithNoEnrichment(true)
.WithNoTotal());
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)
{
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.
var shouldScript =
!context.ShouldSkipScripting() ||
!context.NoScripting() ||
!context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name));
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 =
Context.Admin(app).Clone(b => b
.WithoutContentEnrichment()
.WithFields(null)
.WithNoEnrichment()
.WithUnpublished()
.WithoutTotal());
.WithNoTotal());
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 =
new Context(user, app).Clone(b => b
.WithoutContentEnrichment()
.WithFields(null)
.WithNoEnrichment()
.WithUnpublished()
.WithoutTotal());
.WithNoTotal());
var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct);
@ -122,9 +123,10 @@ public sealed class ReferencesJintExtension : IJintExtension, IScriptDescriptor
var requestContext =
new Context(user, app).Clone(b => b
.WithoutContentEnrichment()
.WithFields(null)
.WithNoEnrichment()
.WithUnpublished()
.WithoutTotal());
.WithNoTotal());
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.
// ==========================================================================
using System.Globalization;
namespace Squidex.Domain.Apps.Entities;
public static class ContextHeaders
{
public const string NoCacheKeys = "X-NoCacheKeys";
public const string NoScripting = "X-NoScripting";
public const string NoSlowTotal = "X-NoSlowTotal";
public const string NoTotal = "X-NoTotal";
private static readonly char[] Separators = { ',', ';' };
public const string KeyBatchSize = "X-BatchSize";
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)
@ -81,4 +110,29 @@ public static class ContextHeaders
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.
var enrichCacheKeys = !context.ShouldSkipCacheKeys();
var enrichCacheKeys = !context.NoCacheKeys();
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.Threading.Tasks.Dataflow;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
@ -22,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class RuleDequeuerWorker : IBackgroundProcess
{
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 IRuleService ruleService;
private readonly IRuleUsageTracker ruleUsageTracker;
@ -42,9 +41,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
this.ruleUsageTracker = ruleUsageTracker;
this.log = log;
requestBlock =
new PartitionedActionBlock<IRuleEventEntity>(HandleAsync, x => x.Job.ExecutionPartition,
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 });
requestScheduler = new PartitionedScheduler<IRuleEventEntity>(HandleAsync, 32, 2);
}
public Task StartAsync(
@ -60,9 +57,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
{
await (timer?.StopAsync() ?? Task.CompletedTask);
requestBlock.Complete();
await requestBlock.Completion;
await requestScheduler.CompleteAsync();
}
public async Task QueryAsync(
@ -72,7 +67,10 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
{
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)
{
@ -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))
{
@ -91,7 +90,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
{
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 jobResult = ComputeJobResult(response.Status, jobDelay);
@ -108,11 +107,11 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
JobResult = jobResult
};
await ruleEventRepository.UpdateAsync(@event.Job, update);
await ruleEventRepository.UpdateAsync(@event.Job, update, default);
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}.",
@event.Job.RuleId,
@ -120,7 +119,7 @@ public sealed class RuleDequeuerWorker : IBackgroundProcess
}
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)

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

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
@ -312,48 +311,15 @@ public sealed class TagService : ITagService
public async Task ClearAsync(
CancellationToken ct = default)
{
var writerBlock = new ActionBlock<SnapshotResult<State>[]>(async batch =>
{
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));
// 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);
await persistenceFactory.Snapshots.WriteManyAsync(jobs, ct);
}
}
catch (OperationCanceledException ex)
{
// Dataflow swallows operation cancelled exception.
throw new AggregateException(ex);
}
},
new ExecutionDataflowBlockOptions
await Parallel.ForEachAsync(batches, ct, async (batch, ct) =>
{
BoundedCapacity = 2,
MaxDegreeOfParallelism = 1,
MaxMessagesPerTask = 1,
});
// Convert to list for the tests, actually not needed.
var jobs = batch.Where(x => x.Value.Clear()).Select(x => new SnapshotWriteJob<State>(x.Key, x.Value, x.Version)).ToList();
// Create batches of 500 items to clear the tag count for better performance.
var batchBlock = new BatchBlock<SnapshotResult<State>>(500, new GroupingDataflowBlockOptions
{
BoundedCapacity = 500
await persistenceFactory.Snapshots.WriteManyAsync(jobs, ct);
});
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="RefactoringEssentials" Version="5.6.0" 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" />
</ItemGroup>
<ItemGroup>

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
@ -91,31 +92,48 @@ public static class CollectionExtensions
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;
var bucketIndex = 0;
List<T>? bucket = null;
foreach (var item in source)
{
bucket ??= new TSource[size];
bucket[bucketIndex++] = item;
bucket ??= new List<T>(size);
bucket.Add(item);
if (bucketIndex != size)
if (bucket.Count == size)
{
continue;
yield return bucket;
bucket = null;
}
}
if (bucket?.Count > 0)
{
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;
bucketIndex = 0;
if (bucket.Count == size)
{
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.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
using Google.Api;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Squidex.Caching;
@ -93,75 +91,40 @@ public class Rebuilder
{
var store = serviceProvider.GetRequiredService<IStore<TState>>();
var parallelism = Environment.ProcessorCount;
var handledIds = new HashSet<DomainId>();
var handlerErrors = 0;
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
{
var domainObject = domainObjectFactory.Create<T, TState>(id, context);
await domainObject.RebuildStateAsync(ct);
}
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);
}
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;

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

@ -15,6 +15,13 @@ namespace Squidex.Infrastructure.Reflection;
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
{
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);
@ -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 readonly Type targetType;
@ -45,7 +85,7 @@ public static class SimpleMapper
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);
@ -56,7 +96,7 @@ public static class SimpleMapper
try
{
var converted = Convert.ChangeType(value, targetType, culture);
var converted = Convert.ChangeType(value, targetType, context.Culture);
SetValue(target, converted);
}
@ -80,7 +120,7 @@ public static class SimpleMapper
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);
@ -91,7 +131,7 @@ public static class SimpleMapper
try
{
var converted = converter.ConvertFrom(null, culture, value);
var converted = converter.ConvertFrom(null, context.Culture, value);
SetValue(target, converted);
}
@ -113,7 +153,7 @@ public static class SimpleMapper
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);
@ -171,6 +211,13 @@ public static class SimpleMapper
new PropertyAccessor(sourceProperty),
new PropertyAccessor(targetProperty)));
}
else if (IsNullableOf(sourceType, targetType))
{
Mappers.Add(new NullablePropertyMapper(
new PropertyAccessor(sourceProperty),
new PropertyAccessor(targetProperty),
Activator.CreateInstance(targetType)));
}
else
{
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++)
{
var mapper = Mappers[i];
mapper.MapProperty(source, destination, culture);
mapper.MapProperty(source, destination, ref context);
}
return destination;
@ -210,10 +264,10 @@ public static class SimpleMapper
where TSource : 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 TTarget : class
{
@ -221,6 +275,12 @@ public static class SimpleMapper
Guard.NotNull(culture);
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.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<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.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
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 void BidirectionalLinkTo<T>(this ISourceBlock<T> source, ITargetBlock<T> target)
#pragma warning restore RECS0165 // Asynchronous methods should return a Task instead of void
public static async IAsyncEnumerable<T> Buffered<T>(this IAsyncEnumerable<T> source, int capacity,
[EnumeratorCancellation] CancellationToken ct = default)
{
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
{
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.
return;
}
if (!producer.IsCompleted)
{
bufferCompletion.Cancel();
bufferChannel.Writer.TryComplete();
if (target.Completion.IsFaulted && target.Completion.Exception != null)
{
source.Fault(target.Completion.Exception.Flatten());
await Task.WhenAny(producer).ConfigureAwait(false);
}
}
}
}

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

@ -22,7 +22,7 @@ public class AcceptHeader
public sealed class UnpublishedAttribute : BaseAttribute
{
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 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 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 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 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 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]
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);
@ -90,7 +90,7 @@ public sealed class AssetContentController : ApiController
[Obsolete("Use overload with app name")]
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);

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

@ -121,7 +121,7 @@ public sealed class ContentDto : Resource
SchemaName = content.SchemaId.Name
});
if (resources.Context.ShouldFlatten())
if (resources.Context.Flatten())
{
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()
{
var log = A.Fake<ILogger<AssetsBulkUpdateCommandMiddleware>>();
sut = new AssetsBulkUpdateCommandMiddleware(contextProvider, log);
sut = new AssetsBulkUpdateCommandMiddleware(contextProvider);
}
[Fact]
@ -61,7 +59,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual);
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();
}
@ -97,7 +95,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual);
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();
}
@ -134,7 +132,7 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(
A<DeleteAsset>.That.Matches(x => x.AssetId == id), CancellationToken))
A<DeleteAsset>.That.Matches(x => x.AssetId == id), A<CancellationToken>._))
.MustHaveHappened();
}

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

@ -29,7 +29,7 @@ public class AssetQueryParserTests : GivenContext
[Fact]
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);
}

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

@ -28,7 +28,7 @@ public class CalculateTokensTests : GivenContext
{
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);
}

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

@ -38,7 +38,7 @@ public class ConvertTagsTests : GivenContext
{
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);
}

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

@ -63,7 +63,7 @@ public class EnrichForCachingTests : GivenContext
[Fact]
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>._))
.MustNotHaveHappened();
@ -74,7 +74,7 @@ public class EnrichForCachingTests : GivenContext
{
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>._))
.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();
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>._))
.MustNotHaveHappened();

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

@ -137,7 +137,7 @@ public class ScriptAssetTests : GivenContext
private Context ContextWithNoScript()
{
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;
}

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

@ -30,9 +30,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
public ContentsBulkUpdateCommandMiddlewareTests()
{
var log = A.Fake<ILogger<ContentsBulkUpdateCommandMiddleware>>();
sut = new ContentsBulkUpdateCommandMiddleware(contentQuery, contextProvider, log);
sut = new ContentsBulkUpdateCommandMiddleware(contentQuery, contextProvider);
}
[Fact]
@ -82,10 +80,11 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x =>
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.ShouldSkipTotal()),
schemaId.Name, A<Q>.That.Matches(x => x.JsonQuery == query), CancellationToken))
x.NoCleanup() &&
x.NoEnrichment() &&
x.NoTotal()),
schemaId.Id.ToString(),
A<Q>.That.Matches(x => x.JsonQuery == query), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id)));
var command = BulkCommand(BulkUpdateContentType.ChangeStatus, new BulkUpdateJob { Query = query });
@ -108,10 +107,12 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x =>
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.ShouldSkipTotal()),
schemaId.Name, A<Q>.That.Matches(x => x.JsonQuery == query), CancellationToken))
x.NoCleanup() &&
x.NoEnrichment() &&
x.NoTotal()),
schemaId.Id.ToString(),
A<Q>.That.Matches(x => x.JsonQuery == query),
A<CancellationToken>._))
.Returns(ResultList.CreateFrom(1, CreateContent(id)));
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);
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();
}
@ -138,10 +139,12 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
A.CallTo(() => contentQuery.QueryAsync(
A<Context>.That.Matches(x =>
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.ShouldSkipTotal()),
schemaId.Name, A<Q>.That.Matches(x => x.JsonQuery == query), CancellationToken))
x.NoCleanup() &&
x.NoEnrichment() &&
x.NoTotal()),
schemaId.Id.ToString(),
A<Q>.That.Matches(x => x.JsonQuery == query),
A<CancellationToken>._))
.Returns(ResultList.CreateFrom(2,
CreateContent(id1),
CreateContent(id2)));
@ -157,11 +160,11 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id2 && x.Exception == null);
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();
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();
}
@ -180,7 +183,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
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();
}
@ -199,7 +202,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
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();
}
@ -218,7 +221,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
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();
}
@ -237,7 +240,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id != default && x.Exception == null);
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();
}
@ -256,7 +259,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
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();
}
@ -293,7 +296,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
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();
}
@ -330,7 +333,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
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();
}
@ -367,7 +370,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
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();
}
@ -386,7 +389,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
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();
}
@ -423,7 +426,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(
A<ValidateContent>.That.Matches(x => x.ContentId == id), CancellationToken))
A<ValidateContent>.That.Matches(x => x.ContentId == id), A<CancellationToken>._))
.MustHaveHappened();
}
@ -460,7 +463,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.ContentId == id), CancellationToken))
A<DeleteContent>.That.Matches(x => x.ContentId == id), A<CancellationToken>._))
.MustHaveHappened();
}
@ -487,7 +490,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
{
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));
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);
A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), CancellationToken))
A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), A<CancellationToken>._))
.MustHaveHappened();
}
@ -522,7 +525,7 @@ public class ContentsBulkUpdateCommandMiddlewareTests : GivenContext
Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception is DomainObjectNotFoundException);
A.CallTo(() => commandBus.PublishAsync(
A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), CancellationToken))
A<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), A<CancellationToken>._))
.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 =>
x.App == TestApp.Default &&
x.ShouldSkipCleanup() &&
x.ShouldSkipAssetEnrichment() &&
x.NoCleanup() &&
x.NoAssetEnrichment() &&
x.UserPrincipal == requestContext.UserPrincipal);
}
@ -210,8 +210,8 @@ public abstract class GraphQLTestBase : IClassFixture<TranslationsFixture>
{
return A<Context>.That.Matches(x =>
x.App == TestApp.Default &&
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.NoCleanup() &&
x.NoEnrichment() &&
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]
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);
}

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

@ -67,7 +67,7 @@ public class EnrichForCachingTests : GivenContext
[Fact]
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>._))
.MustNotHaveHappened();
@ -78,7 +78,7 @@ public class EnrichForCachingTests : GivenContext
{
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>._))
.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<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));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -114,7 +114,7 @@ public class ResolveAssetsTests : GivenContext
};
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));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -164,7 +164,7 @@ public class ResolveAssetsTests : GivenContext
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);
@ -204,7 +204,7 @@ public class ResolveAssetsTests : GivenContext
Assert.NotNull(contents[0].ReferenceData);
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();
}

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<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));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, default);
@ -134,7 +134,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
};
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));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -187,7 +187,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
};
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));
await sut.EnrichAsync(FrontendContext, contents, schemaProvider, CancellationToken);
@ -249,7 +249,7 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
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);

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

@ -181,7 +181,7 @@ public class ScriptContentTests : GivenContext
private Context ContextWithNoScript()
{
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;
}

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

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

@ -332,4 +332,36 @@ public class CollectionExtensionsTests
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
{
#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 T1 P1 { get; set; }
@ -91,7 +95,6 @@ public class SimpleMapperTests
{
var obj1 = new Class1<long, long>
{
P1 = 6,
P2 = 8
};
var obj2 = SimpleMapper.Map(obj1, new Class2<int, int>());
@ -101,11 +104,10 @@ public class SimpleMapperTests
}
[Fact]
public void Should_map_from_nullable()
public void Should_map_from_convertible_nullable()
{
var obj1 = new Class1<long?, long?>
{
P1 = 6,
P2 = 8
};
var obj2 = SimpleMapper.Map(obj1, new Class2<long, long>());
@ -115,11 +117,23 @@ public class SimpleMapperTests
}
[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>
{
P1 = 6,
P2 = 8
};
var obj2 = SimpleMapper.Map(obj1, new Class2<long?, long?>());
@ -128,12 +142,24 @@ public class SimpleMapperTests
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]
public void Should_map_if_convertible_is_null()
{
var obj1 = new Class1<int?, int?>
{
P1 = null,
P2 = null
};
var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>());
@ -147,7 +173,6 @@ public class SimpleMapperTests
{
var obj1 = new Class1<RefToken, RefToken>
{
P1 = RefToken.User("1"),
P2 = RefToken.User("2")
};
var obj2 = SimpleMapper.Map(obj1, new Class2<string, string>());
@ -161,7 +186,6 @@ public class SimpleMapperTests
{
var obj1 = new Class1<long, long>
{
P1 = long.MaxValue,
P2 = long.MaxValue
};
var obj2 = SimpleMapper.Map(obj1, new Class2<int, int>());
@ -171,19 +195,22 @@ public class SimpleMapperTests
}
[Fact]
public void Should_ignore_write_only()
public void Should_ignore_read_only()
{
var obj1 = new Writeonly<int>();
var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>());
var obj1 = new Class1<int, int>
{
P1 = 10
};
var obj2 = SimpleMapper.Map(obj1, new Readonly<int>());
Assert.Equal(0, obj2.P1);
}
[Fact]
public void Should_ignore_read_only()
public void Should_ignore_write_only()
{
var obj1 = new Class1<int, int> { P1 = 10 };
var obj2 = SimpleMapper.Map(obj1, new Readonly<int>());
var obj1 = new Writeonly<int>();
var obj2 = SimpleMapper.Map(obj1, new Class1<int, int>());
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.
// ==========================================================================
using System.Threading.Tasks.Dataflow;
namespace Squidex.Infrastructure.Tasks;
public class PartitionedActionBlockTests
@ -23,31 +21,24 @@ public class PartitionedActionBlockTests
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);
lists[x.P].Add(x.V);
lists[item.Partition].Add(item.Value);
return Task.CompletedTask;
}, x => x.P, new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = 100,
MaxMessagesPerTask = 1,
BoundedCapacity = 100
});
}, 32, 10000);
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 block.Completion;
await scheduler.CompleteAsync();
foreach (var list in lists)
{

Loading…
Cancel
Save