From ff8dc10a22e7a964fc6a11aad4364cad33d152ad Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Jul 2020 17:41:31 +0200 Subject: [PATCH 1/3] Fix equality test. --- .../Squidex.Infrastructure/Reflection/SimpleEquals.cs | 9 ++++++++- .../Reflection/SimpleEqualsTests.cs | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs b/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs index 63d928b21..b04cf7708 100644 --- a/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs +++ b/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs @@ -18,9 +18,16 @@ namespace Squidex.Infrastructure.Reflection public static class SimpleEquals { private static readonly ConcurrentDictionary Comparers = new ConcurrentDictionary(); + private static readonly HashSet SimpleTypes = new HashSet(); private static readonly DefaultComparer DefaultComparer = new DefaultComparer(); private static readonly NoopComparer NoopComparer = new NoopComparer(); + static SimpleEquals() + { + SimpleTypes.Add(typeof(string)); + SimpleTypes.Add(typeof(Uri)); + } + internal static IDeepComparer Build(Type type) { return BuildCore(type) ?? DefaultComparer; @@ -91,7 +98,7 @@ namespace Squidex.Infrastructure.Reflection private static bool IsSimpleType(Type type) { - return type.IsValueType || type == typeof(string); + return type.IsValueType || SimpleTypes.Contains(type); } private static bool IsEquatable(Type type) diff --git a/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs index fab1d637d..ef155d05e 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs @@ -84,6 +84,12 @@ namespace Squidex.Infrastructure.Reflection TimeSpan.FromMilliseconds(123), TimeSpan.FromMilliseconds(55) }; + + yield return new object[] + { + new Uri("/url1", UriKind.Relative), + new Uri("/url2", UriKind.Relative), + }; } [Theory] From e0383106aa61f3f1cc699d7da20a2b556c28d9a6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Jul 2020 17:52:53 +0200 Subject: [PATCH 2/3] Rich editor fix --- .../components/forms/rich-editor.component.ts | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/frontend/app/shared/components/forms/rich-editor.component.ts b/frontend/app/shared/components/forms/rich-editor.component.ts index 256b6cd5e..9fbc5c997 100644 --- a/frontend/app/shared/components/forms/rich-editor.component.ts +++ b/frontend/app/shared/components/forms/rich-editor.component.ts @@ -128,38 +128,48 @@ export class RichEditorComponent extends StatefulControlComponent { - const value = editor.getContent(); - - if (this.value !== value) { - this.value = value; - - self.callChange(value); - } + self.onValueChanged(); }); editor.on('paste', (event: ClipboardEvent) => { + let hasFileDropped = false; + if (event.clipboardData) { for (let i = 0; i < event.clipboardData.items.length; i++) { const file = event.clipboardData.items[i].getAsFile(); if (file && ImageTypes.indexOf(file.type) >= 0) { self.uploadFile(file); + + hasFileDropped = true; } } } + + if (!hasFileDropped) { + self.onValueChanged(); + } }); editor.on('drop', (event: DragEvent) => { + let hasFileDropped = false; + if (event.dataTransfer) { for (let i = 0; i < event.dataTransfer.files.length; i++) { const file = event.dataTransfer.files.item(i); if (file && ImageTypes.indexOf(file.type) >= 0) { self.uploadFile(file); + + hasFileDropped = true; } } } + if (!hasFileDropped) { + self.onValueChanged(); + } + return false; }); @@ -172,6 +182,16 @@ export class RichEditorComponent extends StatefulControlComponent Date: Mon, 13 Jul 2020 11:21:04 +0200 Subject: [PATCH 3/3] Feature/replicated cache (#543) * Performance improvements. --- .drone.yml | 4 + .../src/Migrations/Migrations/AddPatterns.cs | 2 +- .../Contents/MongoContentCollectionAll.cs | 4 +- .../MongoContentCollectionPublished.cs | 4 +- .../MongoContentRepository_SnapshotStore.cs | 24 ++-- .../Contents/Operations/QueryContentsByIds.cs | 8 +- .../Contents/Operations/QueryIdsAsync.cs | 2 +- .../AppProvider.cs | 111 +++++++++++----- .../Apps/Indexes/AppsIndex.cs | 123 ++++++++++++------ .../Apps/Indexes/IAppsIndex.cs | 4 +- .../Contents/DefaultWorkflowsValidator.cs | 2 +- .../Contents/DynamicContentWorkflow.cs | 2 +- .../Contents/Queries/ContentQueryService.cs | 6 +- .../Contents/ReferencesFluidExtension.cs | 2 +- .../IAppProvider.cs | 10 +- .../Rules/Guards/RuleTriggerValidator.cs | 2 +- .../Rules/Indexes/RulesIndex.cs | 30 ++--- .../Schemas/Indexes/ISchemasIndex.cs | 6 +- .../Schemas/Indexes/SchemasIndex.cs | 93 +++++++++---- .../Squidex.Infrastructure/Caching/IPubSub.cs | 18 +++ .../Caching/IPubSubSubscription.cs | 13 ++ .../Caching/IReplicatedCache.cs | 20 +++ ...eExtensions.cs => LocalCacheExtensions.cs} | 33 ++--- .../Caching/ReplicatedCache.cs | 72 ++++++++++ .../Caching/SimplePubSub.cs | 32 +++++ .../Orleans/IPubSubGrain.cs | 19 +++ .../Orleans/IPubSubGrainObserver.cs | 19 +++ .../Orleans/OrleansPubSub.cs | 79 +++++++++++ .../Orleans/OrleansPubSubGrain.cs | 42 ++++++ .../Security/Permission.Part.cs | 4 +- .../EnrichWithSchemaIdCommandMiddleware.cs | 70 ++-------- .../src/Squidex.Web/Pipeline/AppResolver.cs | 4 +- .../Squidex.Web/Pipeline/SchemaResolver.cs | 13 +- .../Controllers/Schemas/SchemasController.cs | 2 +- .../IdentityServer/Config/LazyClientStore.cs | 2 +- .../Config/Domain/InfrastructureServices.cs | 6 + backend/src/Squidex/Program.cs | 1 - .../Apps/Indexes/AppsIndexTests.cs | 104 +++++++++++---- .../Contents/ContentDomainObjectTests.cs | 4 +- .../DefaultWorkflowsValidatorTests.cs | 4 +- .../Contents/DynamicContentWorkflowTests.cs | 2 +- .../Contents/MongoDb/ContentsQueryFixture.cs | 2 +- .../Queries/ContentQueryServiceTests.cs | 8 +- .../Contents/ReferenceFluidExtensionTests.cs | 2 +- .../Rules/Guards/GuardRuleTests.cs | 2 +- .../Triggers/ContentChangedTriggerTests.cs | 6 +- .../Rules/Indexes/RulesIndexTests.cs | 12 +- .../Schemas/Indexes/SchemasIndexTests.cs | 113 +++++++++++----- .../Caching/AsyncLocalCacheTests.cs | 36 +++-- .../Caching/ReplicatedCacheTests.cs | 100 ++++++++++++++ .../Orleans/PubSubTests.cs | 79 +++++++++++ .../Squidex.Infrastructure.Tests.csproj | 1 + ...nrichWithSchemaIdCommandMiddlewareTests.cs | 77 ++--------- .../Pipeline/AppResolverTests.cs | 37 +++--- .../Pipeline/SchemaResolverTests.cs | 60 +++++++-- backend/tools/k6/docker-compose.yml | 1 + backend/tools/k6/get-clients.js | 13 +- backend/tools/k6/get-content.js | 33 +++++ backend/tools/k6/shared.js | 46 +++++-- backend/tools/k6/test.js | 23 ++++ 60 files changed, 1216 insertions(+), 437 deletions(-) create mode 100644 backend/src/Squidex.Infrastructure/Caching/IPubSub.cs create mode 100644 backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs create mode 100644 backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs rename backend/src/Squidex.Infrastructure/Caching/{RequestCacheExtensions.cs => LocalCacheExtensions.cs} (50%) create mode 100644 backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs create mode 100644 backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs create mode 100644 backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs create mode 100644 backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs create mode 100644 backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs create mode 100644 backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs create mode 100644 backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs create mode 100644 backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs create mode 100644 backend/tools/k6/get-content.js create mode 100644 backend/tools/k6/test.js diff --git a/.drone.yml b/.drone.yml index c44c9b078..3ff23d5a4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -18,6 +18,7 @@ steps: - pull_request branch: - master + - release/* - name: build_dev image: docker @@ -48,6 +49,7 @@ steps: - pull_request branch: - master + - release/* - name: test_run image: mcr.microsoft.com/dotnet/core/sdk:3.1-buster @@ -63,6 +65,7 @@ steps: - pull_request branch: - master + - release/* - name: test_cleanup image: docker/compose @@ -83,6 +86,7 @@ steps: - pull_request branch: - master + - release/* - name: push_dev image: docker diff --git a/backend/src/Migrations/Migrations/AddPatterns.cs b/backend/src/Migrations/Migrations/AddPatterns.cs index df55f092e..82a389ca9 100644 --- a/backend/src/Migrations/Migrations/AddPatterns.cs +++ b/backend/src/Migrations/Migrations/AddPatterns.cs @@ -34,7 +34,7 @@ namespace Migrations.Migrations foreach (var id in ids) { - var app = await indexForApps.GetAppAsync(id); + var app = await indexForApps.GetAppAsync(id, false); if (app != null && app.Patterns.Count == 0) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs index 9d92b587b..ae98a5bd4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs @@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("QueryAsyncByIds")) { - var result = await queryContentsById.DoAsync(app.Id, schema, ids); + var result = await queryContentsById.DoAsync(app.Id, schema, ids, false); return ResultList.Create(result.Count, result.Select(x => x.Content)); } @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("QueryAsyncByIdsWithoutSchema")) { - var result = await queryContentsById.DoAsync(app.Id, null, ids); + var result = await queryContentsById.DoAsync(app.Id, null, ids, false); return result; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs index 80a1b4574..3b3d13319 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("QueryAsyncByIds")) { - var result = await queryContentsById.DoAsync(app.Id, schema, ids); + var result = await queryContentsById.DoAsync(app.Id, schema, ids, true); return ResultList.Create(result.Count, result.Select(x => x.Content)); } @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("QueryAsyncByIdsWithoutSchema")) { - var result = await queryContentsById.DoAsync(app.Id, null, ids); + var result = await queryContentsById.DoAsync(app.Id, null, ids, true); return result; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 1f730705d..c2f1ae900 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -53,6 +53,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { var schema = await GetSchemaAsync(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId); + if (schema == null) + { + return (null!, EtagVersion.NotFound); + } + contentEntity.ParseData(schema.SchemaDef, converter); return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); @@ -73,9 +78,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); - await Task.WhenAll( - UpsertDraftContentAsync(value, oldVersion, newVersion, schema), - UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema)); + if (schema == null) + { + return; + } + + var saveDraft = UpsertDraftContentAsync(value, oldVersion, newVersion, schema); + var savePublic = UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema); + + await Task.WhenAll(saveDraft, savePublic); } } @@ -132,15 +143,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collectionPublished.UpsertVersionedAsync(content.Id, oldVersion, content); } - private async Task GetSchemaAsync(Guid appId, Guid schemaId) + private async Task GetSchemaAsync(Guid appId, Guid schemaId) { var schema = await appProvider.GetSchemaAsync(appId, schemaId, true); - if (schema == null) - { - throw new DomainObjectNotFoundException(schemaId.ToString(), typeof(ISchemaEntity)); - } - return schema; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs index afa377614..ab5e3e9ad 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs @@ -28,14 +28,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations this.appProvider = appProvider; } - public async Task> DoAsync(Guid appId, ISchemaEntity? schema, HashSet ids) + public async Task> DoAsync(Guid appId, ISchemaEntity? schema, HashSet ids, bool canCache) { Guard.NotNull(ids, nameof(ids)); var find = Collection.Find(CreateFilter(appId, ids)); var contentItems = await find.ToListAsync(); - var contentSchemas = await GetSchemasAsync(appId, schema, contentItems); + var contentSchemas = await GetSchemasAsync(appId, schema, contentItems, canCache); var result = new List<(IContentEntity Content, ISchemaEntity Schema)>(); @@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return result; } - private async Task> GetSchemasAsync(Guid appId, ISchemaEntity? schema, List contentItems) + private async Task> GetSchemasAsync(Guid appId, ISchemaEntity? schema, List contentItems, bool canCache) { var schemas = new Dictionary(); @@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { if (!schemas.ContainsKey(schemaId)) { - var found = await appProvider.GetSchemaAsync(appId, schemaId); + var found = await appProvider.GetSchemaAsync(appId, schemaId, false, canCache); if (found != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs index 6a9f1d467..2b0a8e773 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs @@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public async Task> DoAsync(Guid appId, Guid schemaId, FilterNode filterNode) { - var schema = await appProvider.GetSchemaAsync(appId, schemaId); + var schema = await appProvider.GetSchemaAsync(appId, schemaId, false); if (schema == null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs index e28a49e90..dc51bfc54 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Indexes; @@ -40,24 +41,16 @@ namespace Squidex.Domain.Apps.Entities this.indexSchemas = indexSchemas; } - public Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id) + public async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id, bool canCache = false) { - return localCache.GetOrCreateAsync($"GetAppWithSchemaAsync({appId}, {id})", async () => - { - return await GetAppWithSchemaUncachedAsync(appId, id); - }); - } - - private async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaUncachedAsync(Guid appId, Guid id) - { - var app = await GetAppAsync(appId); + var app = await GetAppAsync(appId, canCache); if (app == null) { return (null, null); } - var schema = await GetSchemaAsync(appId, id, false); + var schema = await GetSchemaAsync(appId, id, false, canCache); if (schema == null) { @@ -67,60 +60,114 @@ namespace Squidex.Domain.Apps.Entities return (app, schema); } - public Task GetAppAsync(Guid appId) + public async Task GetAppAsync(Guid appId, bool canCache = false) { - return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () => + var app = await localCache.GetOrCreateAsync(AppCacheKey(appId), () => { - return await indexForApps.GetAppAsync(appId); + return indexForApps.GetAppAsync(appId, canCache); }); + + if (app != null) + { + localCache.Add(AppCacheKey(app.Id), app); + } + + return app?.IsArchived == true ? null : app; } - public Task GetAppAsync(string appName) + public async Task GetAppAsync(string appName, bool canCache = false) { - return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => + var app = await localCache.GetOrCreateAsync(AppCacheKey(appName), () => { - return await indexForApps.GetAppByNameAsync(appName); + return indexForApps.GetAppByNameAsync(appName, canCache); }); + + if (app != null) + { + localCache.Add(AppCacheKey(app.Id), app); + } + + return app?.IsArchived == true ? null : app; } - public Task> GetUserAppsAsync(string userId, PermissionSet permissions) + public async Task GetSchemaAsync(Guid appId, string name, bool canCache = false) { - return localCache.GetOrCreateAsync($"GetUserApps({userId})", async () => + var schema = await localCache.GetOrCreateAsync(SchemaCacheKey(appId, name), () => { - return await indexForApps.GetAppsForUserAsync(userId, permissions); + return indexSchemas.GetSchemaByNameAsync(appId, name, canCache); }); + + if (schema != null) + { + localCache.Add(SchemaCacheKey(appId, schema.Id), schema); + } + + return schema?.IsDeleted == true ? null : schema; } - public Task GetSchemaAsync(Guid appId, string name) + public async Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false, bool canCache = false) { - return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () => + var schema = await localCache.GetOrCreateAsync(SchemaCacheKey(appId, id), () => { - return await indexSchemas.GetSchemaByNameAsync(appId, name); + return indexSchemas.GetSchemaAsync(appId, id, canCache); }); + + if (schema != null) + { + localCache.Add(SchemaCacheKey(appId, schema.Id), schema); + } + + return schema?.IsDeleted == true && !allowDeleted ? null : schema; } - public Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) + public async Task> GetUserAppsAsync(string userId, PermissionSet permissions) { - return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {id}, {allowDeleted})", async () => + var apps = await localCache.GetOrCreateAsync($"GetUserApps({userId})", () => { - return await indexSchemas.GetSchemaAsync(appId, id, allowDeleted); + return indexForApps.GetAppsForUserAsync(userId, permissions); }); + + return apps.Where(x => !x.IsArchived).ToList(); } - public Task> GetSchemasAsync(Guid appId) + public async Task> GetSchemasAsync(Guid appId) { - return localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", async () => + var schemas = await localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", () => { - return await indexSchemas.GetSchemasAsync(appId); + return indexSchemas.GetSchemasAsync(appId); }); + + return schemas.Where(x => !x.IsDeleted).ToList(); } - public Task> GetRulesAsync(Guid appId) + public async Task> GetRulesAsync(Guid appId) { - return localCache.GetOrCreateAsync($"GetRulesAsync({appId})", async () => + var rules = await localCache.GetOrCreateAsync($"GetRulesAsync({appId})", () => { - return await indexRules.GetRulesAsync(appId); + return indexRules.GetRulesAsync(appId); }); + + return rules.Where(x => !x.IsDeleted).ToList(); + } + + private static string AppCacheKey(Guid appId) + { + return $"APPS_ID_{appId}"; + } + + private static string AppCacheKey(string appName) + { + return $"APPS_NAME_{appName}"; + } + + private static string SchemaCacheKey(Guid appId, Guid id) + { + return $"SCHEMAS_ID_{appId}_{id}"; + } + + private static string SchemaCacheKey(Guid appId, string name) + { + return $"SCHEMAS_NAME_{appId}_{name}"; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs index abfac7046..6df748be1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Orleans; @@ -23,13 +24,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes { public sealed class AppsIndex : IAppsIndex, ICommandMiddleware { + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); private readonly IGrainFactory grainFactory; + private readonly IReplicatedCache replicatedCache; - public AppsIndex(IGrainFactory grainFactory) + public AppsIndex(IGrainFactory grainFactory, IReplicatedCache replicatedCache) { Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(replicatedCache, nameof(replicatedCache)); this.grainFactory = grainFactory; + + this.replicatedCache = replicatedCache; } public async Task RebuildByContributorsAsync(Guid appId, HashSet contributors) @@ -78,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes var apps = await Task.WhenAll(ids - .Select(GetAppAsync)); + .Select(id => GetAppAsync(id, false))); return apps.NotNull().ToList(); } @@ -96,16 +102,24 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes var apps = await Task.WhenAll(ids .SelectMany(x => x).Distinct() - .Select(GetAppAsync)); + .Select(id => GetAppAsync(id, false))); return apps.NotNull().ToList(); } } - public async Task GetAppByNameAsync(string name) + public async Task GetAppByNameAsync(string name, bool canCache = false) { using (Profiler.TraceMethod()) { + if (canCache) + { + if (replicatedCache.TryGetValue(GetCacheKey(name), out var cached)) + { + return cached as IAppEntity; + } + } + var appId = await GetAppIdAsync(name); if (appId == default) @@ -113,22 +127,30 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes return null; } - return await GetAppAsync(appId); + return await GetAppAsync(appId, canCache); } } - public async Task GetAppAsync(Guid appId) + public async Task GetAppAsync(Guid appId, bool canCache) { using (Profiler.TraceMethod()) { - var app = await grainFactory.GetGrain(appId).GetStateAsync(); + if (canCache) + { + if (replicatedCache.TryGetValue(GetCacheKey(appId), out var cached)) + { + return cached as IAppEntity; + } + } + + var app = await GetAppCoreAsync(appId); - if (IsFound(app.Value, false)) + if (app != null) { - return app.Value; + CacheIt(app, false); } - return null; + return app; } } @@ -197,21 +219,28 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes { await next(context); - if (context.IsCompleted) + if (context.IsCompleted && context.Command is AppCommand appCommand) { - switch (context.Command) + var app = await GetAppCoreAsync(appCommand.AppId); + + if (app != null) { - case AssignContributor assignContributor: - await AssignContributorAsync(assignContributor); - break; + CacheIt(app, true); - case RemoveContributor removeContributor: - await RemoveContributorAsync(removeContributor); - break; + switch (context.Command) + { + case AssignContributor assignContributor: + await AssignContributorAsync(assignContributor); + break; - case ArchiveApp archiveApp: - await ArchiveAppAsync(archiveApp); - break; + case RemoveContributor removeContributor: + await RemoveContributorAsync(removeContributor); + break; + + case ArchiveApp archiveApp: + await ArchiveAppAsync(app); + break; + } } } } @@ -238,46 +267,62 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes return null; } - private Task AssignContributorAsync(AssignContributor command) + private async Task AssignContributorAsync(AssignContributor command) { - return Index(command.ContributorId).AddAsync(command.AppId); + await Index(command.ContributorId).AddAsync(command.AppId); } - private Task RemoveContributorAsync(RemoveContributor command) + private async Task RemoveContributorAsync(RemoveContributor command) { - return Index(command.ContributorId).RemoveAsync(command.AppId); + await Index(command.ContributorId).RemoveAsync(command.AppId); } - private async Task ArchiveAppAsync(ArchiveApp command) + private async Task ArchiveAppAsync(IAppEntity app) { - var appId = command.AppId; + await Index().RemoveAsync(app.Id); - var app = await grainFactory.GetGrain(appId).GetStateAsync(); - - if (IsFound(app.Value, true)) + foreach (var contributorId in app.Contributors.Keys) { - await Index().RemoveAsync(appId); + await Index(contributorId).RemoveAsync(app.Id); } + } - foreach (var contributorId in app.Value.Contributors.Keys) + private IAppsByNameIndexGrain Index() + { + return grainFactory.GetGrain(SingleGrain.Id); + } + + private IAppsByUserIndexGrain Index(string id) + { + return grainFactory.GetGrain(id); + } + + private async Task GetAppCoreAsync(Guid appId) + { + var app = (await grainFactory.GetGrain(appId).GetStateAsync()).Value; + + if (app.Version <= EtagVersion.Empty) { - await Index(contributorId).RemoveAsync(appId); + return null; } + + return app; } - private static bool IsFound(IAppEntity entity, bool allowArchived) + private static string GetCacheKey(Guid id) { - return entity.Version > EtagVersion.Empty && (!entity.IsArchived || allowArchived); + return $"APPS_ID_{id}"; } - private IAppsByNameIndexGrain Index() + private static string GetCacheKey(string name) { - return grainFactory.GetGrain(SingleGrain.Id); + return $"APPS_NAME_{name}"; } - private IAppsByUserIndexGrain Index(string id) + private void CacheIt(IAppEntity app, bool publish) { - return grainFactory.GetGrain(id); + replicatedCache.Add(GetCacheKey(app.Id), app, CacheDuration, publish); + replicatedCache.Add(GetCacheKey(app.Name), app, CacheDuration, publish); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs index 383c349d3..a00a0d5bd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs @@ -20,9 +20,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes Task> GetAppsForUserAsync(string userId, PermissionSet permissions); - Task GetAppByNameAsync(string name); + Task GetAppByNameAsync(string name, bool canCache); - Task GetAppAsync(Guid appId); + Task GetAppAsync(Guid appId, bool canCache); Task ReserveAsync(Guid id, string name); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs index 75ddd704b..fc4553e6c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs @@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1) { - var schema = await appProvider.GetSchemaAsync(appId, schemaId); + var schema = await appProvider.GetSchemaAsync(appId, schemaId, false); if (schema != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 5c8c0f109..50acedef2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -130,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { Workflow? result = null; - var app = await appProvider.GetAppAsync(appId); + var app = await appProvider.GetAppAsync(appId, false); if (app != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index 039456f88..0fe9fa259 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -156,14 +156,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { ISchemaEntity? schema = null; + var canCache = !context.IsFrontendClient; + if (Guid.TryParse(schemaIdOrName, out var id)) { - schema = await appProvider.GetSchemaAsync(context.App.Id, id); + schema = await appProvider.GetSchemaAsync(context.App.Id, id, false, canCache); } if (schema == null) { - schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName); + schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName, canCache); } if (schema == null) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs index 9e4bf8511..5da71377c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { if (arguments.Length == 2 && context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent) { - var app = await appProvider.GetAppAsync(enrichedEvent.AppId.Id); + var app = await appProvider.GetAppAsync(enrichedEvent.AppId.Id, false); if (app == null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index bc4dd03ff..be0e2be06 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -17,17 +17,17 @@ namespace Squidex.Domain.Apps.Entities { public interface IAppProvider { - Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id); + Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id, bool canCache = false); - Task GetAppAsync(Guid appId); + Task GetAppAsync(Guid appId, bool canCache = false); - Task GetAppAsync(string appName); + Task GetAppAsync(string appName, bool canCache = false); Task> GetUserAppsAsync(string userId, PermissionSet permissions); - Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); + Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted, bool canCache = false); - Task GetSchemaAsync(Guid appId, string name); + Task GetSchemaAsync(Guid appId, string name, bool canCache = false); Task> GetSchemasAsync(Guid appId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs index 3d7841798..d0a1f4290 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards Guard.NotNull(action, nameof(action)); Guard.NotNull(appProvider, nameof(appProvider)); - var visitor = new RuleTriggerValidator(x => appProvider.GetSchemaAsync(appId, x)); + var visitor = new RuleTriggerValidator(x => appProvider.GetSchemaAsync(appId, x, false)); return action.Accept(visitor); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs index f26ba274e..c71deaed1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes var rules = await Task.WhenAll( - ids.Select(GetRuleAsync)); + ids.Select(GetRuleCoreAsync)); return rules.NotNull().ToList(); } @@ -51,14 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes { using (Profiler.TraceMethod()) { - var ruleEntity = await grainFactory.GetGrain(id).GetStateAsync(); - - if (IsFound(ruleEntity.Value)) - { - return ruleEntity.Value; - } - - return null; + return await GetRuleCoreAsync(id); } } @@ -95,13 +88,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes private async Task DeleteRuleAsync(DeleteRule command) { - var id = command.RuleId; - - var rule = await grainFactory.GetGrain(id).GetStateAsync(); + var rule = await GetRuleAsync(command.RuleId); - if (IsFound(rule.Value)) + if (rule != null) { - await Index(rule.Value.AppId.Id).RemoveAsync(id); + await Index(rule.AppId.Id).RemoveAsync(rule.Id); } } @@ -110,9 +101,16 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes return grainFactory.GetGrain(appId); } - private static bool IsFound(IRuleEntity rule) + private async Task GetRuleCoreAsync(Guid ruleId) { - return rule.Version > EtagVersion.Empty && !rule.IsDeleted; + var rule = (await grainFactory.GetGrain(ruleId).GetStateAsync()).Value; + + if (rule.Version <= EtagVersion.Empty) + { + return null; + } + + return rule; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs index 0031ffe31..b934c40dc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs @@ -13,11 +13,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { public interface ISchemasIndex { - Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); + Task GetSchemaAsync(Guid appId, Guid id, bool canCache); - Task GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false); + Task GetSchemaByNameAsync(Guid appId, string name, bool canCache); - Task> GetSchemasAsync(Guid appId, bool allowDeleted = false); + Task> GetSchemasAsync(Guid appId); Task RebuildAsync(Guid appId, Dictionary schemas); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs index c0a7b3ba4..bc22580e6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Validation; @@ -20,13 +21,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { public sealed class SchemasIndex : ICommandMiddleware, ISchemasIndex { + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); private readonly IGrainFactory grainFactory; + private readonly IReplicatedCache replicatedCache; - public SchemasIndex(IGrainFactory grainFactory) + public SchemasIndex(IGrainFactory grainFactory, IReplicatedCache replicatedCache) { Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(replicatedCache, nameof(replicatedCache)); this.grainFactory = grainFactory; + + this.replicatedCache = replicatedCache; } public Task RebuildAsync(Guid appId, Dictionary schemas) @@ -34,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return Index(appId).RebuildAsync(schemas); } - public async Task> GetSchemasAsync(Guid appId, bool allowDeleted = false) + public async Task> GetSchemasAsync(Guid appId) { using (Profiler.TraceMethod()) { @@ -42,16 +48,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes var schemas = await Task.WhenAll( - ids.Select(id => GetSchemaAsync(appId, id, allowDeleted))); + ids.Select(id => GetSchemaAsync(appId, id, false))); return schemas.NotNull().ToList(); } } - public async Task GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false) + public async Task GetSchemaByNameAsync(Guid appId, string name, bool canCache) { using (Profiler.TraceMethod()) { + var cacheKey = GetCacheKey(appId, name); + + if (canCache) + { + if (replicatedCache.TryGetValue(cacheKey, out var cachedSchema)) + { + return cachedSchema as ISchemaEntity; + } + } + var id = await GetSchemaIdAsync(appId, name); if (id == default) @@ -59,22 +75,32 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return null; } - return await GetSchemaAsync(appId, id, allowDeleted); + return await GetSchemaAsync(appId, id, canCache); } } - public async Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) + public async Task GetSchemaAsync(Guid appId, Guid id, bool canCache) { using (Profiler.TraceMethod()) { - var schema = await grainFactory.GetGrain(id).GetStateAsync(); + var cacheKey = GetCacheKey(appId, id); + + if (canCache) + { + if (replicatedCache.TryGetValue(cacheKey, out var cachedSchema)) + { + return cachedSchema as ISchemaEntity; + } + } - if (IsFound(schema.Value, allowDeleted)) + var schema = await GetSchemaCoreAsync(id); + + if (schema != null) { - return schema.Value; + CacheIt(schema, false); } - return null; + return schema; } } @@ -125,11 +151,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { await next(context); - if (context.IsCompleted) + if (context.IsCompleted && context.Command is SchemaCommand schemaCommand) { - if (context.Command is DeleteSchema deleteSchema) + var schema = await GetSchemaCoreAsync(schemaCommand.SchemaId); + + if (schema != null) { - await DeleteSchemaAsync(deleteSchema); + CacheIt(schema, true); + + if (context.Command is DeleteSchema) + { + await DeleteSchemaAsync(schema); + } } } } @@ -156,26 +189,42 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return null; } - private async Task DeleteSchemaAsync(DeleteSchema commmand) + private Task DeleteSchemaAsync(ISchemaEntity schema) + { + return Index(schema.AppId.Id).RemoveAsync(schema.Id); + } + + private ISchemasByAppIndexGrain Index(Guid appId) { - var schemaId = commmand.SchemaId; + return grainFactory.GetGrain(appId); + } - var schema = await grainFactory.GetGrain(schemaId).GetStateAsync(); + private async Task GetSchemaCoreAsync(Guid schemaId) + { + var schema = (await grainFactory.GetGrain(schemaId).GetStateAsync()).Value; - if (IsFound(schema.Value, true)) + if (schema.Version <= EtagVersion.Empty) { - await Index(schema.Value.AppId.Id).RemoveAsync(schemaId); + return null; } + + return schema; } - private ISchemasByAppIndexGrain Index(Guid appId) + private string GetCacheKey(Guid appId, string name) { - return grainFactory.GetGrain(appId); + return $"SCHEMAS_NAME_{appId}_{name}"; + } + + private string GetCacheKey(Guid appId, Guid id) + { + return $"SCHEMAS_ID_{appId}_{id}"; } - private static bool IsFound(ISchemaEntity entity, bool allowDeleted) + private void CacheIt(ISchemaEntity schema, bool publish) { - return entity.Version > EtagVersion.Empty && (!entity.IsDeleted || allowDeleted); + replicatedCache.Add(GetCacheKey(schema.AppId.Id, schema.Id), schema, CacheDuration, publish); + replicatedCache.Add(GetCacheKey(schema.AppId.Id, schema.SchemaDef.Name), schema, CacheDuration, publish); } } } diff --git a/backend/src/Squidex.Infrastructure/Caching/IPubSub.cs b/backend/src/Squidex.Infrastructure/Caching/IPubSub.cs new file mode 100644 index 000000000..a2dbad92e --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Caching/IPubSub.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Caching +{ + public interface IPubSub + { + void Publish(object message); + + void Subscribe(Action handler); + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs b/backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs new file mode 100644 index 000000000..c735e6d08 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Caching +{ + internal interface IPubSubSubscription + { + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs b/backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs new file mode 100644 index 000000000..075e515f9 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Caching +{ + public interface IReplicatedCache + { + void Add(string key, object? value, TimeSpan expiration, bool invalidate); + + void Remove(string key); + + bool TryGetValue(string key, out object? value); + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs b/backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs similarity index 50% rename from backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs rename to backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs index 2a3d6ed8c..aa50178bb 100644 --- a/backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs @@ -10,34 +10,27 @@ using System.Threading.Tasks; namespace Squidex.Infrastructure.Caching { - public static class RequestCacheExtensions + public static class LocalCacheExtensions { public static async Task GetOrCreateAsync(this ILocalCache cache, object key, Func> task) { - if (cache.TryGetValue(key, out var value) && value is T typedValue) + if (cache.TryGetValue(key, out var value)) { - return typedValue; + if (value is T typed) + { + return typed; + } + else + { + return default!; + } } - typedValue = await task(); + var result = await task(); - cache.Add(key, typedValue); + cache.Add(key, result); - return typedValue; - } - - public static T GetOrCreate(this ILocalCache cache, object key, Func task) - { - if (cache.TryGetValue(key, out var value) && value is T typedValue) - { - return typedValue; - } - - typedValue = task(); - - cache.Add(key, typedValue); - - return typedValue; + return result; } } } diff --git a/backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs b/backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs new file mode 100644 index 000000000..e9ded4109 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.Caching.Memory; + +namespace Squidex.Infrastructure.Caching +{ + public sealed class ReplicatedCache : IReplicatedCache + { + private readonly Guid instanceId = Guid.NewGuid(); + private readonly IMemoryCache memoryCache; + private readonly IPubSub pubSub; + + public class InvalidateMessage + { + public Guid Source { get; set; } + + public string Key { get; set; } + } + + public ReplicatedCache(IMemoryCache memoryCache, IPubSub pubSub) + { + Guard.NotNull(memoryCache, nameof(memoryCache)); + Guard.NotNull(pubSub, nameof(pubSub)); + + this.memoryCache = memoryCache; + + this.pubSub = pubSub; + this.pubSub.Subscribe(OnMessage); + } + + private void OnMessage(object message) + { + if (message is InvalidateMessage invalidate && invalidate.Source != instanceId) + { + memoryCache.Remove(invalidate.Key); + } + } + + public void Add(string key, object? value, TimeSpan expiration, bool invalidate) + { + memoryCache.Set(key, value, expiration); + + if (invalidate) + { + Invalidate(key); + } + } + + public void Remove(string key) + { + memoryCache.Remove(key); + + Invalidate(key); + } + + public bool TryGetValue(string key, out object? value) + { + return memoryCache.TryGetValue(key, out value); + } + + private void Invalidate(string key) + { + pubSub.Publish(new InvalidateMessage { Key = key, Source = instanceId }); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs b/backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs new file mode 100644 index 000000000..7a30c2ddb --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; + +namespace Squidex.Infrastructure.Caching +{ + public sealed class SimplePubSub : IPubSub + { + private readonly List> handlers = new List>(); + + public void Publish(object message) + { + foreach (var handler in handlers) + { + handler(message); + } + } + + public void Subscribe(Action handler) + { + Guard.NotNull(handler, nameof(handler)); + + handlers.Add(handler); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs b/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs new file mode 100644 index 000000000..3c45bd87d --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; + +namespace Squidex.Infrastructure.Orleans +{ + public interface IPubSubGrain : IGrainWithStringKey + { + Task SubscribeAsync(IPubSubGrainObserver observer); + + Task PublishAsync(object message); + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs b/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs new file mode 100644 index 000000000..a7ea60e54 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Orleans; + +namespace Squidex.Infrastructure.Orleans +{ + public interface IPubSubGrainObserver : IGrainObserver + { + void Handle(object message); + + void Subscribe(Action handler); + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs b/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs new file mode 100644 index 000000000..a222c09d9 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs @@ -0,0 +1,79 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Caching; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Orleans +{ + public sealed class OrleansPubSub : IBackgroundProcess, IPubSub + { + private readonly IPubSubGrain pubSubGrain; + private readonly IPubSubGrainObserver pubSubGrainObserver = new Observer(); + private readonly IGrainFactory grainFactory; + + private sealed class Observer : IPubSubGrainObserver + { + private readonly List> subscriptions = new List>(); + + public void Handle(object message) + { + foreach (var subscription in subscriptions) + { + try + { + subscription(message); + } + catch + { + continue; + } + } + } + + public void Subscribe(Action handler) + { + subscriptions.Add(handler); + } + } + + public OrleansPubSub(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + + pubSubGrain = grainFactory.GetGrain(SingleGrain.Id); + } + + public async Task StartAsync(CancellationToken ct) + { + var reference = await grainFactory.CreateObjectReference(pubSubGrainObserver); + + await pubSubGrain.SubscribeAsync(reference); + } + + public void Publish(object message) + { + Guard.NotNull(message, nameof(message)); + + pubSubGrain.PublishAsync(message).Forget(); + } + + public void Subscribe(Action handler) + { + Guard.NotNull(handler, nameof(handler)); + + pubSubGrainObserver.Subscribe(handler); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs b/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs new file mode 100644 index 000000000..776287d13 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Orleans; + +namespace Squidex.Infrastructure.Orleans +{ + public sealed class OrleansPubSubGrain : Grain, IPubSubGrain + { + private readonly List subscriptions = new List(); + + public Task PublishAsync(object message) + { + foreach (var subscription in subscriptions) + { + try + { + subscription.Handle(message); + } + catch + { + continue; + } + } + + return Task.CompletedTask; + } + + public Task SubscribeAsync(IPubSubGrainObserver observer) + { + subscriptions.Add(observer); + + return Task.CompletedTask; + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs b/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs index 8b6934dd0..d05e4ce0d 100644 --- a/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs +++ b/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs @@ -132,9 +132,11 @@ namespace Squidex.Infrastructure.Security private static int CountOf(ReadOnlySpan text, char character) { + var length = text.Length; + var count = 0; - for (var i = 0; i < text.Length; i++) + for (var i = 0; i < length; i++) { if (text[i] == character) { diff --git a/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs b/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs index 1247447b0..c4b32516c 100644 --- a/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -7,10 +7,8 @@ using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Http; using Squidex.Domain.Apps.Entities; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -19,38 +17,27 @@ namespace Squidex.Web.CommandMiddlewares { public sealed class EnrichWithSchemaIdCommandMiddleware : ICommandMiddleware { - private readonly IAppProvider appProvider; - private readonly IActionContextAccessor actionContextAccessor; + private readonly IHttpContextAccessor httpContextAccessor; - public EnrichWithSchemaIdCommandMiddleware(IAppProvider appProvider, IActionContextAccessor actionContextAccessor) + public EnrichWithSchemaIdCommandMiddleware(IHttpContextAccessor httpContextAccessor) { - Guard.NotNull(appProvider, nameof(appProvider)); - Guard.NotNull(actionContextAccessor, nameof(actionContextAccessor)); + Guard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - this.appProvider = appProvider; - - this.actionContextAccessor = actionContextAccessor; + this.httpContextAccessor = httpContextAccessor; } public async Task HandleAsync(CommandContext context, NextDelegate next) { - if (actionContextAccessor.ActionContext == null) - { - await next(context); - - return; - } - if (context.Command is ISchemaCommand schemaCommand && schemaCommand.SchemaId == null) { - var schemaId = await GetSchemaIdAsync(context); + var schemaId = GetSchemaId(); schemaCommand.SchemaId = schemaId!; } if (context.Command is SchemaCommand schemaSelfCommand && schemaSelfCommand.SchemaId == Guid.Empty) { - var schemaId = await GetSchemaIdAsync(context); + var schemaId = GetSchemaId(); schemaSelfCommand.SchemaId = schemaId?.Id ?? Guid.Empty; } @@ -58,49 +45,16 @@ namespace Squidex.Web.CommandMiddlewares await next(context); } - private async Task?> GetSchemaIdAsync(CommandContext context) + private NamedId GetSchemaId() { - NamedId? appId = null; - - if (context.Command is IAppCommand appCommand) - { - appId = appCommand.AppId; - } + var feature = httpContextAccessor.HttpContext.Features.Get(); - if (appId == null) + if (feature == null) { - appId = actionContextAccessor.ActionContext.HttpContext.Context().App?.NamedId(); - } - - if (appId != null) - { - var routeValues = actionContextAccessor.ActionContext.RouteData.Values; - - if (routeValues.ContainsKey("name")) - { - var schemaName = routeValues["name"].ToString()!; - - ISchemaEntity? schema; - - if (Guid.TryParse(schemaName, out var id)) - { - schema = await appProvider.GetSchemaAsync(appId.Id, id); - } - else - { - schema = await appProvider.GetSchemaAsync(appId.Id, schemaName); - } - - if (schema == null) - { - throw new DomainObjectNotFoundException(schemaName, typeof(ISchemaEntity)); - } - - return schema.NamedId(); - } + throw new InvalidOperationException("Cannot resolve schema."); } - return null; + return feature.SchemaId; } } } \ No newline at end of file diff --git a/backend/src/Squidex.Web/Pipeline/AppResolver.cs b/backend/src/Squidex.Web/Pipeline/AppResolver.cs index 909bfd3b0..bd6ce35ad 100644 --- a/backend/src/Squidex.Web/Pipeline/AppResolver.cs +++ b/backend/src/Squidex.Web/Pipeline/AppResolver.cs @@ -41,7 +41,9 @@ namespace Squidex.Web.Pipeline if (!string.IsNullOrWhiteSpace(appName)) { - var app = await appProvider.GetAppAsync(appName); + var canCache = !user.IsInClient(DefaultClients.Frontend); + + var app = await appProvider.GetAppAsync(appName, canCache); if (app == null) { diff --git a/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs b/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs index fe9f90356..72ee7c4fe 100644 --- a/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs +++ b/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs @@ -6,12 +6,15 @@ // ========================================================================== using System; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; namespace Squidex.Web.Pipeline { @@ -36,7 +39,7 @@ namespace Squidex.Web.Pipeline if (!string.IsNullOrWhiteSpace(schemaIdOrName)) { - var schema = await GetSchemaAsync(appId, schemaIdOrName); + var schema = await GetSchemaAsync(appId, schemaIdOrName, context.HttpContext.User); if (schema == null) { @@ -51,15 +54,17 @@ namespace Squidex.Web.Pipeline await next(); } - private Task GetSchemaAsync(Guid appId, string schemaIdOrName) + private Task GetSchemaAsync(Guid appId, string schemaIdOrName, ClaimsPrincipal user) { + var canCache = !user.IsInClient(DefaultClients.Frontend); + if (Guid.TryParse(schemaIdOrName, out var id)) { - return appProvider.GetSchemaAsync(appId, id); + return appProvider.GetSchemaAsync(appId, id, false, canCache); } else { - return appProvider.GetSchemaAsync(appId, schemaIdOrName); + return appProvider.GetSchemaAsync(appId, schemaIdOrName, canCache); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index eb49eb3f5..68a678d88 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -80,7 +80,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas if (Guid.TryParse(name, out var id)) { - schema = await appProvider.GetSchemaAsync(AppId, id); + schema = await appProvider.GetSchemaAsync(AppId, id, false); } else { diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs b/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs index 3d36fb6d6..ce23bffe7 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs @@ -65,7 +65,7 @@ namespace Squidex.Areas.IdentityServer.Config if (!string.IsNullOrWhiteSpace(appName) && !string.IsNullOrWhiteSpace(appClientId)) { - var app = await appProvider.GetAppAsync(appName); + var app = await appProvider.GetAppAsync(appName, true); var appClient = app?.Clients.GetOrDefault(appClientId); diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index 23c5ca589..debaf394e 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -59,6 +59,12 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .AsOptional(); diff --git a/backend/src/Squidex/Program.cs b/backend/src/Squidex/Program.cs index 00b462958..a2cb7d1ed 100644 --- a/backend/src/Squidex/Program.cs +++ b/backend/src/Squidex/Program.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Net; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs index fa487905d..e467d224a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs @@ -9,10 +9,13 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Orleans; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Security; @@ -41,13 +44,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes A.CallTo(() => grainFactory.GetGrain(userId, null)) .Returns(indexByUser); - sut = new AppsIndex(grainFactory); + var cache = new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub()); + + sut = new AppsIndex(grainFactory, cache); } [Fact] public async Task Should_resolve_all_apps_from_user_permissions() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdsAsync(A.That.IsSameSequenceAs(new[] { appId.Name }))) .Returns(new List { appId.Id }); @@ -60,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_all_apps_from_user() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByUser.GetIdsAsync()) .Returns(new List { appId.Id }); @@ -73,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_combined_apps() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdsAsync(A.That.IsSameSequenceAs(new[] { appId.Name }))) .Returns(new List { appId.Id }); @@ -90,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_all_apps() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdsAsync()) .Returns(new List { appId.Id }); @@ -103,42 +108,91 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_app_by_name() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdAsync(appId.Name)) .Returns(appId.Id); - var actual = await sut.GetAppByNameAsync(appId.Name); + var actual1 = await sut.GetAppByNameAsync(appId.Name, false); + var actual2 = await sut.GetAppByNameAsync(appId.Name, false); + + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + + A.CallTo(() => grainFactory.GetGrain(appId.Id, null)) + .MustHaveHappenedTwiceExactly(); - Assert.Same(expected, actual); + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustHaveHappenedTwiceExactly(); + } + + [Fact] + public async Task Should_resolve_app_by_name_and_id_if_cached_before() + { + var expected = SetupApp(); + + A.CallTo(() => indexByName.GetIdAsync(appId.Name)) + .Returns(appId.Id); + + var actual1 = await sut.GetAppByNameAsync(appId.Name, true); + var actual2 = await sut.GetAppByNameAsync(appId.Name, true); + var actual3 = await sut.GetAppAsync(appId.Id, true); + + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + Assert.Same(expected, actual3); + + A.CallTo(() => grainFactory.GetGrain(appId.Id, null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustHaveHappenedOnceExactly(); } [Fact] public async Task Should_resolve_app_by_id() { - var expected = SetupApp(0, false); + var expected = SetupApp(); + + var actual1 = await sut.GetAppAsync(appId.Id, false); + var actual2 = await sut.GetAppAsync(appId.Id, false); - var actual = await sut.GetAppAsync(appId.Id); + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + + A.CallTo(() => grainFactory.GetGrain(appId.Id, null)) + .MustHaveHappenedTwiceExactly(); - Assert.Same(expected, actual); + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustNotHaveHappened(); } [Fact] - public async Task Should_return_null_if_app_archived() + public async Task Should_resolve_app_by_id_and_name_if_cached_before() { - SetupApp(0, true); + var expected = SetupApp(); - var actual = await sut.GetAppAsync(appId.Id); + var actual1 = await sut.GetAppAsync(appId.Id, true); + var actual2 = await sut.GetAppAsync(appId.Id, true); + var actual3 = await sut.GetAppByNameAsync(appId.Name, true); - Assert.Null(actual); + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + Assert.Same(expected, actual3); + + A.CallTo(() => grainFactory.GetGrain(appId.Id, null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustNotHaveHappened(); } [Fact] public async Task Should_return_null_if_app_not_created() { - SetupApp(-1, false); + SetupApp(EtagVersion.NotFound); - var actual = await sut.GetAppAsync(appId.Id); + var actual = await sut.GetAppAsync(appId.Id, false); Assert.Null(actual); } @@ -258,6 +312,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_add_app_to_index_on_contributor_assignment() { + SetupApp(); + var command = new AssignContributor { AppId = appId.Id, ContributorId = userId }; var context = @@ -273,6 +329,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_remove_from_user_index_on_remove_of_contributor() { + SetupApp(); + var command = new RemoveContributor { AppId = appId.Id, ContributorId = userId }; var context = @@ -285,10 +343,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes .MustHaveHappened(); } - [Theory, InlineData(true), InlineData(false)] - public async Task Should_remove_app_from_indexes_on_archive(bool isArchived) + [Fact] + public async Task Should_remove_app_from_indexes_on_archive() { - SetupApp(0, isArchived); + SetupApp(); var command = new ArchiveApp { AppId = appId.Id }; @@ -365,16 +423,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes .MustHaveHappened(); } - private IAppEntity SetupApp(long version, bool archived) + private IAppEntity SetupApp(long version = 0) { var appEntity = A.Fake(); + A.CallTo(() => appEntity.Id) + .Returns(appId.Id); A.CallTo(() => appEntity.Name) .Returns(appId.Name); A.CallTo(() => appEntity.Version) .Returns(version); - A.CallTo(() => appEntity.IsArchived) - .Returns(archived); A.CallTo(() => appEntity.Contributors) .Returns(AppContributors.Empty.Assign(userId, Role.Owner)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs index 24d54dc09..a470aac2d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs @@ -94,10 +94,10 @@ namespace Squidex.Domain.Apps.Entities.Contents schema = Mocks.Schema(AppNamedId, SchemaNamedId, schemaDef); - A.CallTo(() => appProvider.GetAppAsync(AppName)) + A.CallTo(() => appProvider.GetAppAsync(AppName, false)) .Returns(app); - A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId)) + A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId, false)) .Returns((app, schema)); A.CallTo(() => scriptEngine.TransformAsync(A._, A._, ScriptOptions())) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs index 43c5a26b5..59e0107c0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs @@ -29,10 +29,10 @@ namespace Squidex.Domain.Apps.Entities.Contents { var schema = Mocks.Schema(appId, schemaId, new Schema(schemaId.Name)); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .Returns(Task.FromResult(null)); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false)) .Returns(schema); sut = new DefaultWorkflowsValidator(appProvider); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs index 0f89f5878..abe4c653c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs @@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var workflows = Workflows.Empty.Set(workflow).Set(Guid.NewGuid(), simpleWorkflow); - A.CallTo(() => appProvider.GetAppAsync(appId.Id)) + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) .Returns(app); A.CallTo(() => app.Workflows) 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 fc85d18db..821c583e9 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 @@ -143,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb { var appProvider = A.Fake(); - A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, false, false)) .ReturnsLazily(x => Task.FromResult(CreateSchema(x.GetArgument(0)!, x.GetArgument(1)!))); return appProvider; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs index 990d923ee..af74c5bf9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs @@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries SetupEnricher(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, A._)) .Returns(schema); A.CallTo(() => queryParser.ParseQueryAsync(A._, schema, A._)) @@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries var ctx = CreateContext(isFrontend: false, allowSchema: true); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, true)) .Returns(schema); var result = await sut.GetSchemaOrThrowAsync(ctx, input); @@ -89,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries var ctx = CreateContext(isFrontend: false, allowSchema: true); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, true)) .Returns(schema); var result = await sut.GetSchemaOrThrowAsync(ctx, input); @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var ctx = CreateContext(isFrontend: false, allowSchema: true); - A.CallTo(() => appProvider.GetSchemaAsync(A._, A._)) + A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, true)) .Returns((ISchemaEntity?)null); await Assert.ThrowsAsync(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs index a02f7c6b6..39bba76a9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents new ReferencesFluidExtension(contentQuery, appProvider) }; - A.CallTo(() => appProvider.GetAppAsync(appId.Id)) + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) .Returns(Mocks.App(appId)); sut = new FluidTemplateEngine(extensions); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs index 7273c1468..a0f8fccfb 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards public GuardRuleTests() { - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false)) .Returns(Mocks.Schema(appId, schemaId)); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs index 10c05ec97..b102d6842 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs @@ -42,14 +42,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers new ValidationError("Schema id is required.", "Schemas") }); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .MustNotHaveHappened(); } [Fact] public async Task Should_add_error_if_schemas_ids_are_not_valid() { - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false)) .Returns(Task.FromResult(null)); var trigger = new ContentChangedTriggerV2 @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers [Fact] public async Task Should_not_add_error_if_schemas_ids_are_valid() { - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .Returns(Mocks.Schema(appId, schemaId)); var trigger = new ContentChangedTriggerV2 diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs index d8407428b..c9700f957 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs @@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_resolve_rules_by_id() { - var rule = SetupRule(0, false); + var rule = SetupRule(0); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { rule.Id }); @@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_return_empty_rule_if_rule_not_created() { - var rule = SetupRule(-1, false); + var rule = SetupRule(-1); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { rule.Id }); @@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_return_empty_rule_if_rule_deleted() { - var rule = SetupRule(-1, false); + var rule = SetupRule(-1); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { rule.Id }); @@ -93,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_remove_rule_from_index_on_delete() { - var rule = SetupRule(0, false); + var rule = SetupRule(0); var command = new DeleteRule { RuleId = rule.Id }; @@ -118,11 +118,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes .MustHaveHappened(); } - private IRuleEntity SetupRule(long version, bool deleted) + private IRuleEntity SetupRule(long version) { var ruleId = Guid.NewGuid(); - var ruleEntity = new RuleEntity { Id = ruleId, AppId = appId, Version = version, IsDeleted = deleted }; + var ruleEntity = new RuleEntity { Id = ruleId, AppId = appId, Version = version }; var ruleGrain = A.Fake(); A.CallTo(() => ruleGrain.GetStateAsync()) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs index 60fc48cfe..834c919ed 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs @@ -9,10 +9,13 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Orleans; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Validation; @@ -36,36 +39,97 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes A.CallTo(() => grainFactory.GetGrain(appId.Id, null)) .Returns(index); - sut = new SchemasIndex(grainFactory); + var cache = new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub()); + + sut = new SchemasIndex(grainFactory, cache); + } + + [Fact] + public async Task Should_resolve_schema_by_name() + { + var expected = SetupSchema(); + + A.CallTo(() => index.GetIdAsync(schemaId.Name)) + .Returns(schemaId.Id); + + var actual1 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, false); + var actual2 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, false); + + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + + A.CallTo(() => grainFactory.GetGrain(schemaId.Id, null)) + .MustHaveHappenedTwiceExactly(); + + A.CallTo(() => index.GetIdAsync(A._)) + .MustHaveHappenedTwiceExactly(); + } + + [Fact] + public async Task Should_resolve_schema_by_name_and_id_if_cached_before() + { + var expected = SetupSchema(); + + A.CallTo(() => index.GetIdAsync(schemaId.Name)) + .Returns(schemaId.Id); + + var actual1 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true); + var actual2 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true); + var actual3 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true); + + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + Assert.Same(expected, actual3); + + A.CallTo(() => grainFactory.GetGrain(schemaId.Id, null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => index.GetIdAsync(A._)) + .MustHaveHappenedOnceExactly(); } [Fact] public async Task Should_resolve_schema_by_id() { - var schema = SetupSchema(0, false); + var expected = SetupSchema(); + + var actual1 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, false); + var actual2 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, false); + + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); - var actual = await sut.GetSchemaAsync(appId.Id, schema.Id); + A.CallTo(() => grainFactory.GetGrain(schemaId.Id, null)) + .MustHaveHappenedTwiceExactly(); - Assert.Same(actual, schema); + A.CallTo(() => index.GetIdAsync(A._)) + .MustNotHaveHappened(); } [Fact] - public async Task Should_resolve_schema_by_name() + public async Task Should_resolve_schema_by_id_and_name_if_cached_before() { - var schema = SetupSchema(0, false); + var expected = SetupSchema(); - A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name)) - .Returns(schema.Id); + var actual1 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true); + var actual2 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true); + var actual3 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true); - var actual = await sut.GetSchemaByNameAsync(appId.Id, schema.SchemaDef.Name); + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + Assert.Same(expected, actual3); - Assert.Same(actual, schema); + A.CallTo(() => grainFactory.GetGrain(schemaId.Id, null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => index.GetIdAsync(A._)) + .MustNotHaveHappened(); } [Fact] public async Task Should_resolve_schemas_by_id() { - var schema = SetupSchema(0, false); + var schema = SetupSchema(); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { schema.Id }); @@ -78,7 +142,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes [Fact] public async Task Should_return_empty_schema_if_schema_not_created() { - var schema = SetupSchema(-1, false); + var schema = SetupSchema(EtagVersion.NotFound); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { schema.Id }); @@ -91,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes [Fact] public async Task Should_return_empty_schema_if_schema_deleted() { - var schema = SetupSchema(0, true); + var schema = SetupSchema(); A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name)) .Returns(schema.Id); @@ -101,19 +165,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes Assert.Empty(actual); } - [Fact] - public async Task Should_also_return_schema_if_deleted_allowed() - { - var schema = SetupSchema(-1, true); - - A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name)) - .Returns(schema.Id); - - var actual = await sut.GetSchemasAsync(appId.Id, true); - - Assert.Empty(actual); - } - [Fact] public async Task Should_add_schema_to_index_on_create() { @@ -190,10 +241,10 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes .MustNotHaveHappened(); } - [Theory, InlineData(true), InlineData(false)] - public async Task Should_remove_schema_from_index_on_delete_when_existed_before(bool isDeleted) + [Fact] + public async Task Should_remove_schema_from_index_on_delete_when_existed_before() { - var schema = SetupSchema(0, isDeleted); + var schema = SetupSchema(); var command = new DeleteSchema { SchemaId = schema.Id }; @@ -223,7 +274,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return new CreateSchema { SchemaId = schemaId.Id, Name = name, AppId = appId }; } - private ISchemaEntity SetupSchema(long version, bool deleted) + private ISchemaEntity SetupSchema(long version = 0) { var schemaEntity = A.Fake(); @@ -235,8 +286,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes .Returns(appId); A.CallTo(() => schemaEntity.Version) .Returns(version); - A.CallTo(() => schemaEntity.IsDeleted) - .Returns(deleted); var schemaGrain = A.Fake(); diff --git a/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs index 65911bb77..4ac0b04a9 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs @@ -24,19 +24,13 @@ namespace Squidex.Infrastructure.Caching await Task.Delay(5); - var found = sut.TryGetValue("Key", out var value); - - Assert.True(found); - Assert.Equal(1, value); + AssertCache(sut, "Key", 1, true); await Task.Delay(5); sut.Remove("Key"); - var foundAfterRemove = sut.TryGetValue("Key", out value); - - Assert.False(foundAfterRemove); - Assert.Null(value); + AssertCache(sut, "Key", null, false); } } @@ -47,19 +41,13 @@ namespace Squidex.Infrastructure.Caching await Task.Delay(5); - var found = sut.TryGetValue("Key", out var value); - - Assert.False(found); - Assert.Null(value); + AssertCache(sut, "Key", null, false); sut.Remove("Key"); await Task.Delay(5); - var foundAfterRemove = sut.TryGetValue("Key", out value); - - Assert.False(foundAfterRemove); - Assert.Null(value); + AssertCache(sut, "Key", null, false); } [Fact] @@ -67,11 +55,11 @@ namespace Squidex.Infrastructure.Caching { using (sut.StartContext()) { - var value1 = sut.GetOrCreate("Key", () => ++called); + var value1 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called)); await Task.Delay(5); - var value2 = sut.GetOrCreate("Key", () => ++called); + var value2 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called)); Assert.Equal(1, called); Assert.Equal(1, value1); @@ -82,11 +70,11 @@ namespace Squidex.Infrastructure.Caching [Fact] public async Task Should_call_creator_twice_when_context_not_exists() { - var value1 = sut.GetOrCreate("Key", () => ++called); + var value1 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called)); await Task.Delay(5); - var value2 = sut.GetOrCreate("Key", () => ++called); + var value2 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called)); Assert.Equal(2, called); Assert.Equal(1, value1); @@ -123,5 +111,13 @@ namespace Squidex.Infrastructure.Caching Assert.Equal(1, value1); Assert.Equal(2, value2); } + + private static void AssertCache(ILocalCache cache, string key, object? expectedValue, bool expectedFound) + { + var found = cache.TryGetValue(key, out var value); + + Assert.Equal(expectedFound, found); + Assert.Equal(expectedValue, value); + } } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs new file mode 100644 index 000000000..b354bb1a7 --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs @@ -0,0 +1,100 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Squidex.Infrastructure.Caching +{ + public class ReplicatedCacheTests + { + private readonly IPubSub pubSub = new SimplePubSub(); + private readonly ReplicatedCache sut; + + public ReplicatedCacheTests() + { + sut = new ReplicatedCache(CreateMemoryCache(), pubSub); + } + + [Fact] + public void Should_serve_from_cache() + { + sut.Add("Key", 1, TimeSpan.FromMinutes(10), true); + + AssertCache(sut, "Key", 1, true); + + sut.Remove("Key"); + + AssertCache(sut, "Key", null, false); + } + + [Fact] + public async Task Should_not_served_when_expired() + { + sut.Add("Key", 1, TimeSpan.FromMilliseconds(1), true); + + await Task.Delay(100); + + AssertCache(sut, "Key", null, false); + } + + [Fact] + public void Should_not_invalidate_other_instances_when_item_added_and_flag_is_false() + { + var cache1 = new ReplicatedCache(CreateMemoryCache(), pubSub); + var cache2 = new ReplicatedCache(CreateMemoryCache(), pubSub); + + cache1.Add("Key", 1, TimeSpan.FromMinutes(1), false); + cache2.Add("Key", 2, TimeSpan.FromMinutes(1), false); + + AssertCache(cache1, "Key", 1, true); + AssertCache(cache2, "Key", 2, true); + } + + [Fact] + public void Should_invalidate_other_instances_when_item_added_and_flag_is_true() + { + var cache1 = new ReplicatedCache(CreateMemoryCache(), pubSub); + var cache2 = new ReplicatedCache(CreateMemoryCache(), pubSub); + + cache1.Add("Key", 1, TimeSpan.FromMinutes(1), true); + cache2.Add("Key", 2, TimeSpan.FromMinutes(1), true); + + AssertCache(cache1, "Key", null, false); + AssertCache(cache2, "Key", 2, true); + } + + [Fact] + public void Should_invalidate_other_instances_when_item_removed() + { + var cache1 = new ReplicatedCache(CreateMemoryCache(), pubSub); + var cache2 = new ReplicatedCache(CreateMemoryCache(), pubSub); + + cache1.Add("Key", 1, TimeSpan.FromMinutes(1), true); + cache2.Remove("Key"); + + AssertCache(cache1, "Key", null, false); + AssertCache(cache2, "Key", null, false); + } + + private static void AssertCache(IReplicatedCache cache, string key, object? expectedValue, bool expectedFound) + { + var found = cache.TryGetValue(key, out var value); + + Assert.Equal(expectedFound, found); + Assert.Equal(expectedValue, value); + } + + private static MemoryCache CreateMemoryCache() + { + return new MemoryCache(Options.Create(new MemoryCacheOptions())); + } + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs new file mode 100644 index 000000000..e24013483 --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs @@ -0,0 +1,79 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Orleans; +using Orleans.TestingHost; +using Xunit; + +namespace Squidex.Infrastructure.Orleans +{ + public class PubSubTests + { + [Fact] + public async Task Simple_pubsub_tests() + { + var cluster = new TestClusterBuilder(3).Build(); + + await cluster.DeployAsync(); + + var sent = new HashSet + { + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + }; + + var received1 = await CreateSubscriber(cluster.Client, sent.Count); + var received2 = await CreateSubscriber(cluster.Client, sent.Count); + + var pubSub = new OrleansPubSub(cluster.Client); + + foreach (var message in sent) + { + pubSub.Publish(message); + } + + await Task.WhenAny( + Task.WhenAll( + received1, + received2 + ), + Task.Delay(10000)); + + Assert.True(received1.Result.SetEquals(sent)); + Assert.True(received2.Result.SetEquals(sent)); + } + + private async Task>> CreateSubscriber(IGrainFactory grainFactory, int expectedCount) + { + var pubSub = new OrleansPubSub(grainFactory); + + await pubSub.StartAsync(default); + + var received = new HashSet(); + var receivedCompleted = new TaskCompletionSource>(); + + pubSub.Subscribe(message => + { + if (message is Guid guid) + { + received.Add(guid); + } + + if (received.Count == expectedCount) + { + receivedCompleted.TrySetResult(received); + } + }); + + return receivedCompleted.Task; + } + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index e81c73b3d..42be07719 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs index 5328eb81e..20b370a8f 100644 --- a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs @@ -9,102 +9,47 @@ using System; using System.Threading.Tasks; using FakeItEasy; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Routing; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Entities; -using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Web.Pipeline; using Xunit; namespace Squidex.Web.CommandMiddlewares { public class EnrichWithSchemaIdCommandMiddlewareTests { - private readonly IActionContextAccessor actionContextAccessor = A.Fake(); - private readonly IAppProvider appProvider = A.Fake(); + private readonly IHttpContextAccessor httpContextAccessor = A.Fake(); private readonly ICommandBus commandBus = A.Fake(); private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); private readonly HttpContext httpContext = new DefaultHttpContext(); - private readonly ActionContext actionContext = new ActionContext(); private readonly EnrichWithSchemaIdCommandMiddleware sut; public EnrichWithSchemaIdCommandMiddlewareTests() { - actionContext.RouteData = new RouteData(); - actionContext.HttpContext = httpContext; + httpContext.Features.Set(new AppFeature(appId)); - A.CallTo(() => actionContextAccessor.ActionContext) - .Returns(actionContext); + A.CallTo(() => httpContextAccessor.HttpContext) + .Returns(httpContext); - var app = A.Fake(); - - A.CallTo(() => app.Id).Returns(appId.Id); - A.CallTo(() => app.Name).Returns(appId.Name); - - httpContext.Context().App = app; - - var schema = A.Fake(); - - A.CallTo(() => schema.Id).Returns(schemaId.Id); - A.CallTo(() => schema.SchemaDef).Returns(new Schema(schemaId.Name)); - - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) - .Returns(schema); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) - .Returns(schema); - - sut = new EnrichWithSchemaIdCommandMiddleware(appProvider, actionContextAccessor); + sut = new EnrichWithSchemaIdCommandMiddleware(httpContextAccessor); } [Fact] public async Task Should_throw_exception_if_schema_not_found() { - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, "other-schema")) - .Returns(Task.FromResult(null)); - - actionContext.RouteData.Values["name"] = "other-schema"; - var command = new CreateContent { AppId = appId }; var context = Ctx(command); - await Assert.ThrowsAsync(() => sut.HandleAsync(context)); - } - - [Fact] - public async Task Should_do_nothing_when_route_has_no_parameter() - { - var command = new CreateContent(); - var context = Ctx(command); - - await sut.HandleAsync(context); - - Assert.Null(command.Actor); - } - - [Fact] - public async Task Should_assign_schema_id_and_name_from_name() - { - actionContext.RouteData.Values["name"] = schemaId.Name; - - var command = new CreateContent { AppId = appId }; - var context = Ctx(command); - - await sut.HandleAsync(context); - - Assert.Equal(schemaId, command.SchemaId); + await Assert.ThrowsAsync(() => sut.HandleAsync(context)); } [Fact] public async Task Should_assign_schema_id_and_name_from_id() { - actionContext.RouteData.Values["name"] = schemaId.Id; + httpContext.Features.Set(new SchemaFeature(schemaId)); var command = new CreateContent { AppId = appId }; var context = Ctx(command); @@ -117,7 +62,7 @@ namespace Squidex.Web.CommandMiddlewares [Fact] public async Task Should_assign_schema_id_from_id() { - actionContext.RouteData.Values["name"] = schemaId.Name; + httpContext.Features.Set(new SchemaFeature(schemaId)); var command = new UpdateSchema(); var context = Ctx(command); @@ -130,6 +75,8 @@ namespace Squidex.Web.CommandMiddlewares [Fact] public async Task Should_not_override_schema_id() { + httpContext.Features.Set(new SchemaFeature(schemaId)); + var command = new CreateSchema { SchemaId = Guid.NewGuid() }; var context = Ctx(command); @@ -141,6 +88,8 @@ namespace Squidex.Web.CommandMiddlewares [Fact] public async Task Should_not_override_schema_id_and_name() { + httpContext.Features.Set(new SchemaFeature(schemaId)); + var command = new CreateContent { SchemaId = NamedId.Of(Guid.NewGuid(), "other-schema") }; var context = Ctx(command); diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs index f5d11dcec..2b25a85b0 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs @@ -34,13 +34,16 @@ namespace Squidex.Web.Pipeline private readonly ActionContext actionContext; private readonly ActionExecutingContext actionExecutingContext; private readonly ActionExecutionDelegate next; - private readonly ClaimsIdentity user = new ClaimsIdentity(); + private readonly ClaimsIdentity userIdentiy = new ClaimsIdentity(); + private readonly ClaimsPrincipal user; private readonly string appName = "my-app"; private readonly AppResolver sut; private bool isNextCalled; public AppResolverTests() { + user = new ClaimsPrincipal(userIdentiy); + actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor { EndpointMetadata = new List() @@ -48,7 +51,7 @@ namespace Squidex.Web.Pipeline actionExecutingContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), this); actionExecutingContext.HttpContext = httpContext; - actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user); + actionExecutingContext.HttpContext.User = user; actionExecutingContext.RouteData.Values["app"] = appName; next = () => @@ -64,7 +67,7 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_return_not_found_if_app_not_found() { - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, false)) .Returns(Task.FromResult(null)); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -78,10 +81,10 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName, appUser: "user1"); - user.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); - user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); + userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app")); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, true)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -96,9 +99,9 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName, appClient: "client1"); - user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, true)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -113,12 +116,12 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName); - user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); - user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); + userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); actionContext.ActionDescriptor.EndpointMetadata.Add(new AllowAnonymousAttribute()); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, true)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -133,10 +136,10 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName); - user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); - user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); + userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, false)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -150,9 +153,9 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName, appClient: "client1"); - user.AddClaim(new Claim(OpenIdClaims.ClientId, "other:client1")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, "other:client1")); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, false)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -170,7 +173,7 @@ namespace Squidex.Web.Pipeline Assert.True(isNextCalled); - A.CallTo(() => appProvider.GetAppAsync(A._)) + A.CallTo(() => appProvider.GetAppAsync(A._, false)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs index 30db5353e..c099efd75 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs @@ -19,6 +19,8 @@ using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; using Xunit; #pragma warning disable IDE0017 // Simplify object initialization @@ -48,6 +50,7 @@ namespace Squidex.Web.Pipeline actionExecutingContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), this); actionExecutingContext.HttpContext = httpContext; actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user); + actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); next = () => { @@ -62,10 +65,9 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_return_not_found_if_schema_not_found() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, true)) .Returns(Task.FromResult(null)); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -77,12 +79,29 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_resolve_schema_from_id() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); var schema = CreateSchema(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, true)) + .Returns(schema); + + await sut.OnActionExecutionAsync(actionExecutingContext, next); + + Assert.Equal(schemaId, actionContext.HttpContext.Features.Get().SchemaId); + Assert.True(isNextCalled); + } + + [Fact] + public async Task Should_resolve_schema_from_id_without_caching_if_frontend() + { + user.AddClaim(new Claim(OpenIdClaims.ClientId, DefaultClients.Frontend)); + + actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); + + var schema = CreateSchema(); + + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false)) .Returns(schema); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -94,12 +113,11 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_resolve_schema_from_name() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); actionContext.RouteData.Values["name"] = schemaId.Name; var schema = CreateSchema(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, true)) .Returns(schema); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -109,29 +127,45 @@ namespace Squidex.Web.Pipeline } [Fact] - public async Task Should_do_nothing_if_app_feature_not_set() + public async Task Should_resolve_schema_from_name_without_caching_if_frontend() { - actionExecutingContext.RouteData.Values["name"] = schemaId.Name; + user.AddClaim(new Claim(OpenIdClaims.ClientId, DefaultClients.Frontend)); + + actionContext.RouteData.Values["name"] = schemaId.Name; + + var schema = CreateSchema(); + + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, false)) + .Returns(schema); + + await sut.OnActionExecutionAsync(actionExecutingContext, next); + + Assert.Equal(schemaId, actionContext.HttpContext.Features.Get().SchemaId); + Assert.True(isNextCalled); + } + [Fact] + public async Task Should_do_nothing_if_parameter_not_set() + { await sut.OnActionExecutionAsync(actionExecutingContext, next); Assert.True(isNextCalled); - A.CallTo(() => appProvider.GetAppAsync(A._)) + A.CallTo(() => appProvider.GetAppAsync(A._, false)) .MustNotHaveHappened(); } [Fact] - public async Task Should_do_nothing_if_parameter_not_set() + public async Task Should_do_nothing_if_app_feature_not_set() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); - actionExecutingContext.RouteData.Values.Remove("name"); + actionExecutingContext.HttpContext.Features.Set(null!); + actionExecutingContext.RouteData.Values["name"] = schemaId.Name; await sut.OnActionExecutionAsync(actionExecutingContext, next); Assert.True(isNextCalled); - A.CallTo(() => appProvider.GetAppAsync(A._)) + A.CallTo(() => appProvider.GetAppAsync(A._, false)) .MustNotHaveHappened(); } diff --git a/backend/tools/k6/docker-compose.yml b/backend/tools/k6/docker-compose.yml index 3e7d28660..91439c7c2 100644 --- a/backend/tools/k6/docker-compose.yml +++ b/backend/tools/k6/docker-compose.yml @@ -12,6 +12,7 @@ services: - "8086:8086" environment: - INFLUXDB_DB=k6 + - INFLUXDB_HTTP_MAX_BODY_SIZE=0 grafana: image: grafana/grafana:latest diff --git a/backend/tools/k6/get-clients.js b/backend/tools/k6/get-clients.js index ad67f6e2a..5de17f74a 100644 --- a/backend/tools/k6/get-clients.js +++ b/backend/tools/k6/get-clients.js @@ -2,20 +2,19 @@ import { check } from 'k6'; import http from 'k6/http'; import { variables, getBearerToken } from './shared.js'; -export let options = { +export const options = { stages: [ - { duration: "2m", target: 200 }, - { duration: "2m", target: 200 }, + { duration: "2m", target: 500 }, { duration: "2m", target: 0 }, ], thresholds: { - 'http_req_duration': ['p(99)<1500'], // 99% of requests must complete below 1.5s - } + 'http_req_duration': ['p(99)<300'], // 99% of requests must complete below 300ms + }, + discardResponseBodies: true }; - export function setup() { - const token = getBearerToken(); + const token = getBearerToken(variables.appName); return { token }; } diff --git a/backend/tools/k6/get-content.js b/backend/tools/k6/get-content.js new file mode 100644 index 000000000..48c24b692 --- /dev/null +++ b/backend/tools/k6/get-content.js @@ -0,0 +1,33 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { variables, getBearerToken } from './shared.js'; + +export const options = { + stages: [ + { duration: "2m", target: 500 }, + { duration: "2m", target: 0 }, + ], + thresholds: { + 'http_req_duration': ['p(99)<300'], // 99% of requests must complete below 300ms + } +}; + +export function setup() { + const token = getBearerToken('ci-semantic-search'); + + return { token }; +} + +export default function (data) { + const url = `${variables.serverUrl}/api/content/ci-semantic-search/test/5d648f76-7ae9-4141-a325-0c31ed155e5c`; + + const response = http.get(url, { + headers: { + Authorization: `Bearer ${data.token}` + } + }); + + check(response, { + 'is status 200': (r) => r.status === 200, + }); +} diff --git a/backend/tools/k6/shared.js b/backend/tools/k6/shared.js index a8aef7935..5824f8f9a 100644 --- a/backend/tools/k6/shared.js +++ b/backend/tools/k6/shared.js @@ -9,25 +9,53 @@ export const variables = { let bearerToken = null; -export function getBearerToken() { +export function getBearerToken(appName) { if (!bearerToken) { - const url = `${variables.serverUrl}/identity-server/connect/token`; + const adminToken = getToken(variables.clientId, variables.clientSecret); - const response = http.post(url, { - grant_type: 'client_credentials', - client_id: variables.clientId, - client_secret: variables.clientSecret, - scope: 'squidex-api' + const clientsUrl = `${variables.serverUrl}/api/apps/${appName}/clients`; + + const clientsResponse = http.get(clientsUrl, { + headers: { + Authorization: `Bearer ${adminToken}` + } }); - const json = JSON.parse(response.body); + const clientsJson = JSON.parse(clientsResponse.body); + const client = clientsJson.items[0]; + + const clientId = `${appName}:${client.id}`; + const clientSecret = client.secret; + + console.log(`Using ${clientId} / ${clientSecret}`); - bearerToken = json.access_token; + bearerToken = getToken(clientId, clientSecret); } return bearerToken; } +function getToken(clientId, clientSecret) { + const tokenUrl = `${variables.serverUrl}/identity-server/connect/token`; + + const tokenResponse = http.post(tokenUrl, { + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + scope: 'squidex-api' + }, { + responseType: 'text' + }); + + if (tokenResponse.status !== 200) { + throw new Error('Invalid response.'); + } + + const tokenJson = JSON.parse(tokenResponse.body); + + return tokenJson.access_token; +} + function getValue(key, fallback) { const result = __ENV[key] || fallback; diff --git a/backend/tools/k6/test.js b/backend/tools/k6/test.js new file mode 100644 index 000000000..fc958f78c --- /dev/null +++ b/backend/tools/k6/test.js @@ -0,0 +1,23 @@ +import { check } from 'k6'; +import http from 'k6/http'; + +export const options = { + stages: [ + { duration: "2m", target: 300 }, + { duration: "2m", target: 300 }, + { duration: "2m", target: 0 }, + ], + thresholds: { + 'http_req_duration': ['p(99)<300'], // 99% of requests must complete below 300ms + } +}; + +export default function () { + const url = `https://test-api.k6.io/`; + + const response = http.get(url); + + check(response, { + 'is status 200': (r) => r.status === 200, + }); +} \ No newline at end of file