diff --git a/backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs b/backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs index 76cd8d4ca..1d87af1e0 100644 --- a/backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs +++ b/backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs @@ -46,7 +46,7 @@ namespace Migrations.Migrations.MongoDb var actionBlock = new ActionBlock(async batch => { - var updates = new List>(); + var writes = new List>(); foreach (var document in batch) { @@ -92,13 +92,16 @@ namespace Migrations.Migrations.MongoDb var filter = Builders.Filter.Eq("_id", document["_id"].AsString); - updates.Add(new ReplaceOneModel(filter, document) + writes.Add(new ReplaceOneModel(filter, document) { IsUpsert = true }); } - await collectionNew.BulkWriteAsync(updates, writeOptions); + if (writes.Count > 0) + { + await collectionNew.BulkWriteAsync(writes, writeOptions); + } }, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2, diff --git a/backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs b/backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs index 7465af658..d40b43ad3 100644 --- a/backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs +++ b/backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs @@ -102,7 +102,7 @@ namespace Migrations.Migrations.MongoDb var actionBlock = new ActionBlock(async batch => { - var updates = new List>(); + var writes = new List>(); foreach (var document in batch) { @@ -126,13 +126,16 @@ namespace Migrations.Migrations.MongoDb var filter = Builders.Filter.Eq("_id", documentIdNew); - updates.Add(new ReplaceOneModel(filter, document) + writes.Add(new ReplaceOneModel(filter, document) { IsUpsert = true }); } - await collectionNew.BulkWriteAsync(updates, writeOptions); + if (writes.Count > 0) + { + await collectionNew.BulkWriteAsync(writes, writeOptions); + } }, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2, diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs index c74f3b85a..725d4055e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs @@ -75,6 +75,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText } } + if (writes.Count == 0) + { + return Task.CompletedTask; + } + return Collection.BulkWriteAsync(writes); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHash.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHash.cs new file mode 100644 index 000000000..de0867790 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHash.cs @@ -0,0 +1,145 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Driver; +using NodaTime; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.ObjectPool; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas +{ + public sealed class MongoSchemasHash : MongoRepositoryBase, ISchemasHash, IEventConsumer + { + public int BatchSize + { + get { return 1000; } + } + + public int BatchDelay + { + get { return 500; } + } + + public string Name + { + get { return GetType().Name; } + } + + public string EventsFilter + { + get { return "^(app-|schema-)"; } + } + + public MongoSchemasHash(IMongoDatabase database, bool setup = false) + : base(database, setup) + { + } + + protected override string CollectionName() + { + return "SchemasHash"; + } + + public Task On(IEnumerable> events) + { + var writes = new List>(); + + foreach (var @event in events) + { + switch (@event.Payload) + { + case SchemaEvent schemaEvent: + { + writes.Add( + new UpdateOneModel( + Filter.Eq(x => x.AppId, schemaEvent.AppId.Id.ToString()), + Update + .Set($"s.{schemaEvent.SchemaId.Id}", @event.Headers.EventStreamNumber()) + .Set(x => x.Updated, @event.Headers.Timestamp()))); + break; + } + + case AppEvent appEvent: + writes.Add( + new UpdateOneModel( + Filter.Eq(x => x.AppId, appEvent.AppId.Id.ToString()), + Update + .Set(x => x.AppVersion, @event.Headers.EventStreamNumber()) + .Set(x => x.Updated, @event.Headers.Timestamp())) + { + IsUpsert = true + }); + break; + } + } + + if (writes.Count == 0) + { + return Task.CompletedTask; + } + + return Collection.BulkWriteAsync(writes); + } + + public async Task<(Instant Create, string Hash)> GetCurrentHashAsync(DomainId appId) + { + var entity = await Collection.Find(x => x.AppId == appId.ToString()).FirstOrDefaultAsync(); + + if (entity == null) + { + return (default, string.Empty); + } + + var ids = + entity.SchemaVersions.Select(x => (x.Key, x.Value)) + .Union(Enumerable.Repeat((entity.AppId, entity.AppVersion), 1)); + + var hash = CreateHash(ids); + + return (entity.Updated, hash); + } + + public ValueTask ComputeHashAsync(IAppEntity app, IEnumerable schemas) + { + var ids = + schemas.Select(x => (x.Id.ToString(), x.Version)) + .Union(Enumerable.Repeat((app.Id.ToString(), app.Version), 1)); + + var hash = CreateHash(ids); + + return new ValueTask(hash); + } + + private static string CreateHash(IEnumerable<(string, long)> ids) + { + var sb = DefaultPools.StringBuilder.Get(); + try + { + foreach (var (id, version) in ids.OrderBy(x => x.Item1)) + { + sb.Append(id); + sb.Append(version); + sb.Append(';'); + } + + return sb.ToString().Sha256Base64(); + } + finally + { + DefaultPools.StringBuilder.Return(sb); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHashEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHashEntity.cs new file mode 100644 index 000000000..f8306a0a5 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHashEntity.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas +{ + public sealed class MongoSchemasHashEntity + { + [BsonId] + [BsonElement] + public string AppId { get; set; } + + [BsonRequired] + [BsonElement("v")] + public long AppVersion { get; set; } + + [BsonRequired] + [BsonElement("s")] + public Dictionary SchemaVersions { get; set; } + + [BsonRequired] + [BsonElement("t")] + public Instant Updated { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index af85c9ab0..168220057 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -8,31 +8,40 @@ using System; using System.Linq; using System.Threading.Tasks; -using GraphQL.Utilities; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using NodaTime; +using Squidex.Caching; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Log; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class CachingGraphQLService : IGraphQLService { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); - private readonly IMemoryCache cache; - private readonly IServiceProvider resolver; + private readonly IBackgroundCache cache; + private readonly ISchemasHash schemasHash; + private readonly IServiceProvider serviceProvider; private readonly GraphQLOptions options; - public CachingGraphQLService(IMemoryCache cache, IServiceProvider resolver, IOptions options) + public sealed record CacheEntry(GraphQLModel Model, string Hash, Instant Created); + + public CachingGraphQLService(IBackgroundCache cache, ISchemasHash schemasHash, IServiceProvider serviceProvider, IOptions options) { Guard.NotNull(cache, nameof(cache)); - Guard.NotNull(resolver, nameof(resolver)); + Guard.NotNull(schemasHash, nameof(schemasHash)); + Guard.NotNull(serviceProvider, nameof(serviceProvider)); Guard.NotNull(options, nameof(options)); this.cache = cache; - this.resolver = resolver; + this.schemasHash = schemasHash; + this.serviceProvider = serviceProvider; this.options = options.Value; } @@ -43,9 +52,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var model = await GetModelAsync(context.App); - var graphQlContext = new GraphQLExecutionContext(context, resolver); + var executionContext = + serviceProvider.GetRequiredService() + .WithContext(context); - var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, graphQlContext, q))); + var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, executionContext, q))); return (result.Any(x => x.HasError), result.Map(x => x.Response)); } @@ -57,9 +68,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var model = await GetModelAsync(context.App); - var graphQlContext = new GraphQLExecutionContext(context, resolver); + var executionContext = + serviceProvider.GetRequiredService() + .WithContext(context); - var result = await QueryInternalAsync(model, graphQlContext, query); + var result = await QueryInternalAsync(model, executionContext, query); return result; } @@ -83,7 +96,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } } - private Task GetModelAsync(IAppEntity app) + private async Task GetModelAsync(IAppEntity app) + { + var entry = await GetModelEntryAsync(app); + + return entry.Model; + } + + private Task GetModelEntryAsync(IAppEntity app) { if (options.CacheDuration <= 0) { @@ -92,22 +112,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var cacheKey = CreateCacheKey(app.Id, app.Version.ToString()); - return cache.GetOrCreateAsync(cacheKey, async entry => + return cache.GetOrCreateAsync(cacheKey, CacheDuration, async entry => { - entry.AbsoluteExpirationRelativeToNow = CacheDuration; - return await CreateModelAsync(app); + }, + async entry => + { + var (created, hash) = await schemasHash.GetCurrentHashAsync(app.Id); + + return created < entry.Created || string.Equals(hash, entry.Hash, StringComparison.OrdinalIgnoreCase); }); } - private async Task CreateModelAsync(IAppEntity app) + private async Task CreateModelAsync(IAppEntity app) { - var allSchemas = await resolver.GetRequiredService().GetSchemasAsync(app.Id); + var allSchemas = await serviceProvider.GetRequiredService().GetSchemasAsync(app.Id); + + var hash = await schemasHash.ComputeHashAsync(app, allSchemas); - return new GraphQLModel(app, - allSchemas, - resolver.GetRequiredService(), - resolver.GetRequiredService()); + return new CacheEntry( + new GraphQLModel(app, + allSchemas, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()), + hash, + SystemClock.Instance.GetCurrentInstant()); } private static object CreateCacheKey(DomainId appId, string etag) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index 038da926f..92f7327e8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -5,13 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using GraphQL; using GraphQL.DataLoader; -using GraphQL.Utilities; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.Queries; @@ -27,37 +25,53 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private static readonly List EmptyAssets = new List(); private static readonly List EmptyContents = new List(); private readonly IDataLoaderContextAccessor dataLoaderContextAccessor; - private readonly IServiceProvider resolver; + private readonly DataLoaderDocumentListener dataLoaderDocumentListener; + private readonly IUrlGenerator urlGenerator; + private readonly ISemanticLog log; + private readonly ICommandBus commandBus; + private Context context; - public IUrlGenerator UrlGenerator { get; } - - public ICommandBus CommandBus { get; } + public IUrlGenerator UrlGenerator + { + get { return urlGenerator; } + } - public ISemanticLog Log { get; } + public ICommandBus CommandBus + { + get { return commandBus; } + } - public GraphQLExecutionContext(Context context, IServiceProvider resolver) - : base(context - .WithoutCleanup() - .WithoutContentEnrichment(), - resolver.GetRequiredService(), - resolver.GetRequiredService()) + public ISemanticLog Log { - UrlGenerator = resolver.GetRequiredService(); + get { return log; } + } - CommandBus = resolver.GetRequiredService(); + public override Context Context + { + get { return context; } + } - Log = resolver.GetRequiredService(); + public GraphQLExecutionContext(IAssetQueryService assetQuery, IContentQueryService contentQuery, + IDataLoaderContextAccessor dataLoaderContextAccessor, DataLoaderDocumentListener dataLoaderDocumentListener, ICommandBus commandBus, IUrlGenerator urlGenerator, ISemanticLog log) + : base(assetQuery, contentQuery) + { + this.commandBus = commandBus; + this.dataLoaderContextAccessor = dataLoaderContextAccessor; + this.dataLoaderDocumentListener = dataLoaderDocumentListener; + this.urlGenerator = urlGenerator; + this.log = log; + } - dataLoaderContextAccessor = resolver.GetRequiredService(); + public GraphQLExecutionContext WithContext(Context newContext) + { + context = newContext.WithoutCleanup().WithoutContentEnrichment(); - this.resolver = resolver; + return this; } public void Setup(ExecutionOptions execution) { - var loader = resolver.GetRequiredService(); - - execution.Listeners.Add(loader); + execution.Listeners.Add(dataLoaderDocumentListener); execution.UserContext = this; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index 4a7a793fa..659dd0d35 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -15,34 +15,28 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Queries { - public class QueryExecutionContext : Dictionary + public abstract class QueryExecutionContext : Dictionary { private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10); private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); private readonly IContentQueryService contentQuery; private readonly IAssetQueryService assetQuery; - private readonly Context context; - public Context Context - { - get { return context; } - } + public abstract Context Context { get; } - public QueryExecutionContext(Context context, IAssetQueryService assetQuery, IContentQueryService contentQuery) + protected QueryExecutionContext(IAssetQueryService assetQuery, IContentQueryService contentQuery) { Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(contentQuery, nameof(contentQuery)); - Guard.NotNull(context, nameof(context)); this.assetQuery = assetQuery; this.contentQuery = contentQuery; - this.context = context; } public virtual Task FindContentAsync(string schemaIdOrName, DomainId id, long version) { - return contentQuery.FindAsync(context, schemaIdOrName, id, version); + return contentQuery.FindAsync(Context, schemaIdOrName, id, version); } public virtual async Task FindAssetAsync(DomainId id) @@ -54,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - asset = await assetQuery.FindAsync(context, id); + asset = await assetQuery.FindAsync(Context, id); } finally { @@ -79,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - content = await contentQuery.FindAsync(context, schemaId.ToString(), id); + content = await contentQuery.FindAsync(Context, schemaId.ToString(), id); } finally { @@ -104,7 +98,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - assets = await assetQuery.QueryAsync(context, null, q); + assets = await assetQuery.QueryAsync(Context, null, q); } finally { @@ -128,7 +122,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - contents = await contentQuery.QueryAsync(context, schemaIdOrName, q); + contents = await contentQuery.QueryAsync(Context, schemaIdOrName, q); } finally { @@ -156,7 +150,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithIds(notLoadedAssets)); + assets = await assetQuery.QueryAsync(Context, null, Q.Empty.WithIds(notLoadedAssets)); } finally { @@ -185,7 +179,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(notLoadedContents)); + contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(notLoadedContents)); } finally { @@ -208,7 +202,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(); try { - return await contentQuery.QueryAsync(context, schemaIdOrName, q); + return await contentQuery.QueryAsync(Context, schemaIdOrName, q); } finally { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/ISchemasHash.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/ISchemasHash.cs new file mode 100644 index 000000000..4928ce1b9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/ISchemasHash.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using NodaTime; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public interface ISchemasHash + { + Task<(Instant Create, string Hash)> GetCurrentHashAsync(DomainId appId); + + ValueTask ComputeHashAsync(IAppEntity app, IEnumerable schemas); + } +} diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 7e6a78bf7..1a9518f93 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -25,7 +25,7 @@ - + @@ -45,7 +45,4 @@ - - - diff --git a/backend/src/Squidex.Infrastructure/Tasks/AsyncLocalCleaner.cs b/backend/src/Squidex.Infrastructure/Tasks/AsyncLocalCleaner.cs deleted file mode 100644 index c7a06d7c7..000000000 --- a/backend/src/Squidex.Infrastructure/Tasks/AsyncLocalCleaner.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading; - -namespace Squidex.Infrastructure.Tasks -{ - public sealed class AsyncLocalCleaner : IDisposable - { - private readonly AsyncLocal asyncLocal; - - public AsyncLocalCleaner(AsyncLocal asyncLocal) - { - Guard.NotNull(asyncLocal, nameof(asyncLocal)); - - this.asyncLocal = asyncLocal; - } - - public void Dispose() - { - asyncLocal.Value = default!; - } - } -} diff --git a/backend/src/Squidex.Infrastructure/Tasks/AsyncLock.cs b/backend/src/Squidex.Infrastructure/Tasks/AsyncLock.cs deleted file mode 100644 index 3425a60b6..000000000 --- a/backend/src/Squidex.Infrastructure/Tasks/AsyncLock.cs +++ /dev/null @@ -1,73 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading; -using System.Threading.Tasks; - -#pragma warning disable RECS0022 // A catch clause that catches System.Exception and has an empty body - -namespace Squidex.Infrastructure.Tasks -{ - public sealed class AsyncLock - { - private readonly SemaphoreSlim semaphore; - - public AsyncLock() - { - semaphore = new SemaphoreSlim(1); - } - - public Task LockAsync() - { - var wait = semaphore.WaitAsync(); - - if (wait.IsCompleted) - { - return Task.FromResult((IDisposable)new LockReleaser(this)); - } - else - { - return wait.ContinueWith(x => (IDisposable)new LockReleaser(this), - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - } - - private class LockReleaser : IDisposable - { - private AsyncLock? target; - - internal LockReleaser(AsyncLock target) - { - this.target = target; - } - - public void Dispose() - { - var current = target; - - if (current == null) - { - return; - } - - target = null; - - try - { - current.semaphore.Release(); - } - catch - { - // just ignore the Exception - } - } - } - } -} diff --git a/backend/src/Squidex.Infrastructure/Tasks/AsyncLockPool.cs b/backend/src/Squidex.Infrastructure/Tasks/AsyncLockPool.cs deleted file mode 100644 index 6c8e99ba2..000000000 --- a/backend/src/Squidex.Infrastructure/Tasks/AsyncLockPool.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; - -namespace Squidex.Infrastructure.Tasks -{ - public sealed class AsyncLockPool - { - private readonly AsyncLock[] locks; - - public AsyncLockPool(int poolSize) - { - Guard.GreaterThan(poolSize, 0, nameof(poolSize)); - - locks = new AsyncLock[poolSize]; - - for (var i = 0; i < poolSize; i++) - { - locks[i] = new AsyncLock(); - } - } - - public Task LockAsync(object target) - { - Guard.NotNull(target, nameof(target)); - - return locks[Math.Abs(target.GetHashCode() % locks.Length)].LockAsync(); - } - } -} diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index f76db8ee7..d4e7f8835 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -48,6 +48,7 @@ namespace Squidex.Config.Domain services.AddReplicatedCache(); services.AddAsyncLocalCache(); + services.AddBackgroundCache(); services.AddSingletonAs(_ => SystemClock.Instance) .As(); diff --git a/backend/src/Squidex/Config/Domain/QueryServices.cs b/backend/src/Squidex/Config/Domain/QueryServices.cs index ec89bbd8a..e482beb5a 100644 --- a/backend/src/Squidex/Config/Domain/QueryServices.cs +++ b/backend/src/Squidex/Config/Domain/QueryServices.cs @@ -29,6 +29,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddTransientAs() + .AsSelf(); + services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index 793398b3e..4c56f911b 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -25,12 +25,15 @@ using Squidex.Domain.Apps.Entities.MongoDb.Contents; using Squidex.Domain.Apps.Entities.MongoDb.FullText; using Squidex.Domain.Apps.Entities.MongoDb.History; using Squidex.Domain.Apps.Entities.MongoDb.Rules; +using Squidex.Domain.Apps.Entities.MongoDb.Schemas; using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Users; using Squidex.Domain.Users.MongoDb; using Squidex.Domain.Users.MongoDb.Infrastructure; using Squidex.Infrastructure; using Squidex.Infrastructure.Diagnostics; +using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Migrations; using Squidex.Infrastructure.States; @@ -115,6 +118,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs(c => ActivatorUtilities.CreateInstance(c, GetDatabase(c, mongoContentDatabaseName))) .As().As>(); + services.AddSingletonAs() + .AsOptional().As(); + services.AddSingletonAs() .AsOptional(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 5bdd6b090..920afcfc6 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -63,7 +63,7 @@ - + diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs index 57d09ce34..c9f92f529 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs @@ -13,7 +13,6 @@ using MongoDB.Driver; using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.TestHelpers; -using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.MongoDb.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; @@ -28,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.MongoDb private readonly IMongoClient mongoClient = new MongoClient("mongodb://localhost"); private readonly IMongoDatabase mongoDatabase; - public IAssetRepository AssetRepository { get; } + public MongoAssetRepository AssetRepository { get; } public NamedId[] AppIds { get; } = { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 339f35f82..c1ec5c7b5 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Newtonsoft.Json; +using Squidex.Caching; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.TestHelpers; @@ -142,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private CachingGraphQLService CreateSut() { - var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + var cache = new BackgroundCache(new MemoryCache(Options.Create(new MemoryCacheOptions()))); var appProvider = A.Fake(); @@ -161,6 +162,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var services = new ServiceCollection() .AddMemoryCache() + .AddTransient() .AddSingleton(A.Fake()) .AddSingleton(appProvider) .AddSingleton(assetQuery) @@ -173,7 +175,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL FakeUrlGenerator>() .BuildServiceProvider(); - return new CachingGraphQLService(cache, services, Options.Create(new GraphQLOptions())); + var schemasHash = A.Fake(); + + return new CachingGraphQLService(cache, schemasHash, services, Options.Create(new GraphQLOptions())); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs index 194405a71..5f94553f1 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb private readonly IMongoClient mongoClient = new MongoClient("mongodb://localhost"); private readonly IMongoDatabase mongoDatabase; - public IContentRepository ContentRepository { get; } + public MongoContentRepository ContentRepository { get; } public NamedId[] AppIds { get; } = { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MongoDb/SchemasHashFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MongoDb/SchemasHashFixture.cs new file mode 100644 index 000000000..b2f78b562 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MongoDb/SchemasHashFixture.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.MongoDb.Schemas; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Domain.Apps.Entities.Schemas.MongoDb +{ + public sealed class SchemasHashFixture + { + private readonly IMongoClient mongoClient = new MongoClient("mongodb://localhost"); + private readonly IMongoDatabase mongoDatabase; + + public MongoSchemasHash SchemasHash { get; } + + public SchemasHashFixture() + { + InstantSerializer.Register(); + + mongoDatabase = mongoClient.GetDatabase("QueryTests"); + + var schemasHash = new MongoSchemasHash(mongoDatabase); + + Task.Run(async () => + { + await schemasHash.InitializeAsync(); + }).Wait(); + + SchemasHash = schemasHash; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MongoDb/SchemasHashTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MongoDb/SchemasHashTests.cs new file mode 100644 index 000000000..f4ff53b29 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MongoDb/SchemasHashTests.cs @@ -0,0 +1,110 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using NodaTime; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +#pragma warning disable SA1300 // Element should begin with upper-case letter + +namespace Squidex.Domain.Apps.Entities.Schemas.MongoDb +{ + [Trait("Category", "Dependencies")] + public class SchemasHashTests : IClassFixture + { + public SchemasHashFixture _ { get; } + + public SchemasHashTests(SchemasHashFixture fixture) + { + _ = fixture; + } + + [Fact] + public async Task Should_compute_cache_independent_from_order() + { + var app = CreateApp(DomainId.NewGuid(), 1); + + var schema1 = CreateSchema(DomainId.NewGuid(), 2); + var schema2 = CreateSchema(DomainId.NewGuid(), 3); + + var hash1 = await _.SchemasHash.ComputeHashAsync(app, new[] { schema1, schema2 }); + var hash2 = await _.SchemasHash.ComputeHashAsync(app, new[] { schema2, schema1 }); + + Assert.NotNull(hash1); + Assert.NotNull(hash2); + Assert.Equal(hash1, hash2); + } + + [Fact] + public async Task Should_compute_cache_independent_from_db() + { + var app = CreateApp(DomainId.NewGuid(), 1); + + var schema1 = CreateSchema(DomainId.NewGuid(), 2); + var schema2 = CreateSchema(DomainId.NewGuid(), 3); + + var timestamp = SystemClock.Instance.GetCurrentInstant().WithoutMs(); + + var computedHash = await _.SchemasHash.ComputeHashAsync(app, new[] { schema1, schema2 }); + + await _.SchemasHash.On(new[] + { + Envelope.Create(new AppCreated + { + AppId = NamedId.Of(app.Id, "my-app") + }).SetEventStreamNumber(app.Version).SetTimestamp(timestamp), + + Envelope.Create(new SchemaCreated + { + AppId = NamedId.Of(app.Id, "my-app"), + SchemaId = NamedId.Of(schema1.Id, "my-schema") + }).SetEventStreamNumber(schema1.Version).SetTimestamp(timestamp), + + Envelope.Create(new SchemaCreated + { + AppId = NamedId.Of(app.Id, "my-app"), + SchemaId = NamedId.Of(schema2.Id, "my-schema") + }).SetEventStreamNumber(schema2.Version).SetTimestamp(timestamp) + }); + + var (dbTime, dbHash) = await _.SchemasHash.GetCurrentHashAsync(app.Id); + + Assert.Equal(dbHash, computedHash); + Assert.Equal(dbTime, timestamp); + } + + private static IAppEntity CreateApp(DomainId id, long version) + { + var app = A.Fake(); + + A.CallTo(() => app.Id) + .Returns(id); + A.CallTo(() => app.Version) + .Returns(version); + + return app; + } + + private static ISchemaEntity CreateSchema(DomainId id, long version) + { + var schema = A.Fake(); + + A.CallTo(() => schema.Id) + .Returns(id); + A.CallTo(() => schema.Version) + .Returns(version); + + return schema; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index 955025742..ef2d3163d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -27,7 +27,7 @@ - + diff --git a/backend/tests/Squidex.Infrastructure.Tests/Tasks/AsyncLockPoolTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Tasks/AsyncLockPoolTests.cs deleted file mode 100644 index a465fde93..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/Tasks/AsyncLockPoolTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using System.Threading.Tasks; -using Xunit; - -namespace Squidex.Infrastructure.Tasks -{ - public class AsyncLockPoolTests - { - [Fact] - public async Task Should_lock() - { - var sut = new AsyncLockPool(1); - - var value = 0; - - await Task.WhenAll( - Enumerable.Repeat(0, 100).Select(x => new Func(async () => - { - using (await sut.LockAsync(1)) - { - await Task.Yield(); - - value++; - } - })())); - - Assert.Equal(100, value); - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Tasks/AsyncLockTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Tasks/AsyncLockTests.cs deleted file mode 100644 index 99863cf13..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/Tasks/AsyncLockTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using System.Threading.Tasks; -using Xunit; - -namespace Squidex.Infrastructure.Tasks -{ - public class AsyncLockTests - { - [Fact] - public async Task Should_lock() - { - var sut = new AsyncLock(); - - var value = 0; - - await Task.WhenAll( - Enumerable.Repeat(0, 100).Select(x => new Func(async () => - { - using (await sut.LockAsync()) - { - await Task.Yield(); - - value++; - } - })())); - - Assert.Equal(100, value); - } - } -}