mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
60 changed files with 1216 additions and 437 deletions
@ -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<object> handler); |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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 }); |
|||
} |
|||
} |
|||
} |
|||
@ -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<Action<object>> handlers = new List<Action<object>>(); |
|||
|
|||
public void Publish(object message) |
|||
{ |
|||
foreach (var handler in handlers) |
|||
{ |
|||
handler(message); |
|||
} |
|||
} |
|||
|
|||
public void Subscribe(Action<object> handler) |
|||
{ |
|||
Guard.NotNull(handler, nameof(handler)); |
|||
|
|||
handlers.Add(handler); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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<object> handler); |
|||
} |
|||
} |
|||
@ -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<Action<object>> subscriptions = new List<Action<object>>(); |
|||
|
|||
public void Handle(object message) |
|||
{ |
|||
foreach (var subscription in subscriptions) |
|||
{ |
|||
try |
|||
{ |
|||
subscription(message); |
|||
} |
|||
catch |
|||
{ |
|||
continue; |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void Subscribe(Action<object> handler) |
|||
{ |
|||
subscriptions.Add(handler); |
|||
} |
|||
} |
|||
|
|||
public OrleansPubSub(IGrainFactory grainFactory) |
|||
{ |
|||
Guard.NotNull(grainFactory, nameof(grainFactory)); |
|||
|
|||
this.grainFactory = grainFactory; |
|||
|
|||
pubSubGrain = grainFactory.GetGrain<IPubSubGrain>(SingleGrain.Id); |
|||
} |
|||
|
|||
public async Task StartAsync(CancellationToken ct) |
|||
{ |
|||
var reference = await grainFactory.CreateObjectReference<IPubSubGrainObserver>(pubSubGrainObserver); |
|||
|
|||
await pubSubGrain.SubscribeAsync(reference); |
|||
} |
|||
|
|||
public void Publish(object message) |
|||
{ |
|||
Guard.NotNull(message, nameof(message)); |
|||
|
|||
pubSubGrain.PublishAsync(message).Forget(); |
|||
} |
|||
|
|||
public void Subscribe(Action<object> handler) |
|||
{ |
|||
Guard.NotNull(handler, nameof(handler)); |
|||
|
|||
pubSubGrainObserver.Subscribe(handler); |
|||
} |
|||
} |
|||
} |
|||
@ -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<IPubSubGrainObserver> subscriptions = new List<IPubSubGrainObserver>(); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -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())); |
|||
} |
|||
} |
|||
} |
|||
@ -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> |
|||
{ |
|||
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<Task<HashSet<Guid>>> CreateSubscriber(IGrainFactory grainFactory, int expectedCount) |
|||
{ |
|||
var pubSub = new OrleansPubSub(grainFactory); |
|||
|
|||
await pubSub.StartAsync(default); |
|||
|
|||
var received = new HashSet<Guid>(); |
|||
var receivedCompleted = new TaskCompletionSource<HashSet<Guid>>(); |
|||
|
|||
pubSub.Subscribe(message => |
|||
{ |
|||
if (message is Guid guid) |
|||
{ |
|||
received.Add(guid); |
|||
} |
|||
|
|||
if (received.Count == expectedCount) |
|||
{ |
|||
receivedCompleted.TrySetResult(received); |
|||
} |
|||
}); |
|||
|
|||
return receivedCompleted.Task; |
|||
} |
|||
} |
|||
} |
|||
@ -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, |
|||
}); |
|||
} |
|||
@ -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, |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue