Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redis Connection across Tenants #13531

Merged
merged 18 commits into from
Apr 20, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/OrchardCore.Build/Dependencies.props
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<PackageManagement Include="Serilog.AspNetCore" Version="6.1.0" />
<PackageManagement Include="Shortcodes" Version="1.3.3" />
<PackageManagement Include="SixLabors.ImageSharp.Web" Version="2.0.2" />
<PackageManagement Include="StackExchange.Redis" Version="2.6.104" />
<PackageManagement Include="System.Linq.Async" Version="6.0.1" />
<PackageManagement Include="xunit.analyzers" Version="1.1.0" />
<PackageManagement Include="xunit" Version="2.4.2" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,29 @@ namespace OrchardCore.Redis.Options
{
public class RedisCacheOptionsSetup : IConfigureOptions<RedisCacheOptions>
{
private readonly IRedisService _redis;
private readonly string _tenant;
private readonly IOptions<RedisOptions> _redisOptions;

public RedisCacheOptionsSetup(ShellSettings shellSettings, IOptions<RedisOptions> redisOptions)
public RedisCacheOptionsSetup(IRedisService redis, ShellSettings shellSettings)
{
_redis = redis;
_tenant = shellSettings.Name;
_redisOptions = redisOptions;
}

public void Configure(RedisCacheOptions options)
{
options.InstanceName = _redisOptions.Value.InstancePrefix + _tenant;
options.ConfigurationOptions = _redisOptions.Value.ConfigurationOptions;
var redis = _redis;
options.ConnectionMultiplexerFactory = async () =>
{
if (redis.Connection == null)
{
await redis.ConnectAsync();
}

return redis.Connection;
jtkech marked this conversation as resolved.
Show resolved Hide resolved
};

options.InstanceName = $"{redis.InstancePrefix}{_tenant}";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ public RedisKeyManagementOptionsSetup(IRedisService redis, ShellSettings shellSe
public void Configure(KeyManagementOptions options)
{
var redis = _redis;

options.XmlRepository = new RedisXmlRepository(() =>
{
if (redis.Database == null)
Expand All @@ -29,7 +28,7 @@ public void Configure(KeyManagementOptions options)

return redis.Database;
}
, redis.InstancePrefix + _tenant + ":DataProtection-Keys");
, $"({redis.InstancePrefix}{_tenant}:DataProtection-Keys");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.Options;
using StackExchange.Redis;

namespace OrchardCore.Redis.Services;

/// <summary>
/// Wrapper preventing the <see cref="RedisCache"/> to dispose a shared <see cref="IConnectionMultiplexer"/>.
/// </summary>
public class RedisCacheWrapper : IDistributedCache
{
private readonly RedisCache _cache;

public RedisCacheWrapper(IOptions<RedisCacheOptions> optionsAccessor) => _cache = new RedisCache(optionsAccessor);

public byte[] Get(string key) => _cache.Get(key);

public Task<byte[]> GetAsync(string key, CancellationToken token = default) => _cache.GetAsync(key, token);

public void Refresh(string key) => _cache?.Refresh(key);

public Task RefreshAsync(string key, CancellationToken token = default) => _cache.RefreshAsync(key, token);

public void Remove(string key) => _cache!.Remove(key);

public Task RemoveAsync(string key, CancellationToken token = default) => _cache.RemoveAsync(key);

public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => _cache.Set(key, value, options);

public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
=> _cache.SetAsync(key, value, options, token);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;

namespace OrchardCore.Redis.Services;

/// <summary>
/// Factory allowing to share <see cref="IDatabase"/> instances across tenants.
/// </summary>
public sealed class RedisDatabaseFactory : IRedisDatabaseFactory, IDisposable
{
private static readonly ConcurrentDictionary<string, IDatabase> _databases = new();
private static readonly SemaphoreSlim _semaphore = new(1);
private static volatile int _registered;
private static volatile int _refCount;

private readonly IHostApplicationLifetime _lifetime;
private readonly ILogger _logger;

public RedisDatabaseFactory(IHostApplicationLifetime lifetime, ILogger<RedisDatabaseFactory> logger)
{
Interlocked.Increment(ref _refCount);

_lifetime = lifetime;
if (Interlocked.CompareExchange(ref _registered, 1, 0) == 0)
{
_lifetime.ApplicationStopped.Register(Release);
}

_logger = logger;
}

public async Task<IDatabase> CreateAsync(RedisOptions options)
{
if (_databases.TryGetValue(options.Configuration, out var database))
{
return database;
}

await _semaphore.WaitAsync();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a concurrent dictionary. Maybe use TryGetOrAdd(Lazy)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I thought about it but at some point you were not a fan of using Lazy ;)

Okay will do

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends on the context, here I think it's important to not create multiple connections for the same string, so I agree with the locking, and because we have a CD I think the Lazy pattern should be used.

try
{
if (_databases.TryGetValue(options.Configuration, out database))
{
return database;
}

database = (await ConnectionMultiplexer.ConnectAsync(options.ConfigurationOptions)).GetDatabase();

return _databases[options.Configuration] = database;
}
catch (Exception e)
{
_logger.LogError(e, "Unable to connect to Redis.");
throw;
}
finally
{
_semaphore.Release();
}
}

public void Dispose()
{
if (Interlocked.Decrement(ref _refCount) == 0 && _lifetime.ApplicationStopped.IsCancellationRequested)
{
Release();
}
}

internal static void Release()
{
if (Interlocked.CompareExchange(ref _refCount, 0, 0) == 0)
{
var databases = _databases.Values.ToArray();

_databases.Clear();

foreach (var database in databases)
{
database.Multiplexer.Dispose();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -18,14 +17,13 @@ public class RedisLock : IDistributedLock
{
private readonly IRedisService _redis;
private readonly ILogger _logger;

private readonly string _hostName;
private readonly string _prefix;

public RedisLock(IRedisService redis, ShellSettings shellSettings, ILogger<RedisLock> logger)
{
_redis = redis;
_hostName = Dns.GetHostName() + ':' + Process.GetCurrentProcess().Id;
_hostName = Dns.GetHostName() + ':' + System.Environment.ProcessId;
_prefix = redis.InstancePrefix + shellSettings.Name + ':';
_logger = logger;
}
Expand Down
52 changes: 7 additions & 45 deletions src/OrchardCore.Modules/OrchardCore.Redis/Services/RedisService.cs
Original file line number Diff line number Diff line change
@@ -1,67 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OrchardCore.Modules;
using StackExchange.Redis;

namespace OrchardCore.Redis.Services
{
public class RedisService : ModularTenantEvents, IRedisService, IDisposable
public class RedisService : ModularTenantEvents, IRedisService
{
private readonly IRedisDatabaseFactory _factory;
private readonly RedisOptions _options;
private readonly ILogger _logger;

private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);

public RedisService(IOptions<RedisOptions> options, ILogger<RedisService> logger)
public RedisService(IRedisDatabaseFactory factory, IOptions<RedisOptions> options)
{
_factory = factory;
_options = options.Value;
_logger = logger;

InstancePrefix = options.Value.InstancePrefix;
}

public IConnectionMultiplexer Connection { get; private set; }
public IConnectionMultiplexer Connection => Database?.Multiplexer;

public string InstancePrefix { get; }
public string InstancePrefix => _options.InstancePrefix;

public IDatabase Database { get; private set; }

public override Task ActivatingAsync() => ConnectAsync();

public async Task ConnectAsync()
{
if (Database != null)
{
return;
}

await _semaphore.WaitAsync();

try
{
if (Database == null)
{
Connection = await ConnectionMultiplexer.ConnectAsync(_options.ConfigurationOptions);

Database = Connection.GetDatabase();
}
}
catch (Exception e)
{
_logger.LogError(e, "Unable to connect to Redis.");
}
finally
{
_semaphore.Release();
}
}

public void Dispose()
{
Connection?.Close();
}
public async Task ConnectAsync() => Database ??= await _factory.CreateAsync(_options);
}
}
13 changes: 8 additions & 5 deletions src/OrchardCore.Modules/OrchardCore.Redis/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -34,24 +35,26 @@ public override void ConfigureServices(IServiceCollection services)
{
try
{
var configurationString = _configuration["OrchardCore_Redis:Configuration"];
var _ = ConfigurationOptions.Parse(configurationString);
var configuration = _configuration["OrchardCore_Redis:Configuration"];
var configurationOptions = ConfigurationOptions.Parse(configuration);
var instancePrefix = _configuration["OrchardCore_Redis:InstancePrefix"];

services.Configure<RedisOptions>(options =>
{
options.Configuration = configurationString;
options.Configuration = configuration;
options.ConfigurationOptions = configurationOptions;
options.InstancePrefix = instancePrefix;
});
}
catch (Exception e)
{
_logger.LogError("'Redis' features are not active on tenant '{TenantName}' as the 'Configuration' string is missing or invalid: " + e.Message, _tenant);
_logger.LogError(e, "'Redis' features are not active on tenant '{TenantName}' as the 'Configuration' string is missing or invalid.", _tenant);
return;
}

services.AddSingleton<IRedisService, RedisService>();
services.AddSingleton<IModularTenantEvents>(sp => sp.GetRequiredService<IRedisService>());
services.AddSingleton<IRedisDatabaseFactory, RedisDatabaseFactory>();
}
}

Expand All @@ -62,7 +65,7 @@ public override void ConfigureServices(IServiceCollection services)
{
if (services.Any(d => d.ServiceType == typeof(IRedisService)))
{
services.AddStackExchangeRedisCache(o => { });
services.AddSingleton<IDistributedCache, RedisCacheWrapper>();
services.AddTransient<IConfigureOptions<RedisCacheOptions>, RedisCacheOptionsSetup>();
services.AddScoped<ITagCache, RedisTagCache>();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\OrchardCore\OrchardCore.csproj" />
<ProjectReference Include="..\OrchardCore.Data.YesSql\OrchardCore.Data.YesSql.csproj" />
<ProjectReference Include="..\OrchardCore.Infrastructure.Abstractions\OrchardCore.Infrastructure.Abstractions.csproj" />
<ProjectReference Include="..\OrchardCore\OrchardCore.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using StackExchange.Redis;

namespace OrchardCore.Redis;

/// <summary>
/// Factory allowing to share <see cref="IDatabase"/> instances across tenants.
/// </summary>
public interface IRedisDatabaseFactory
{
Task<IDatabase> CreateAsync(RedisOptions options);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ namespace OrchardCore.Redis
public class RedisOptions
{
/// <summary>
/// The configuration used to connect to Redis.
/// The configuration string used to connect to Redis.
/// </summary>
public ConfigurationOptions ConfigurationOptions => ConfigurationOptions.Parse(Configuration);
public string Configuration { get; set; }

/// <summary>
/// The configuration string used to connect to Redis.
/// The configuration used to connect to Redis.
/// </summary>
public string Configuration { get; set; }
public ConfigurationOptions ConfigurationOptions { get; set; }

/// <summary>
/// Prefix alowing a Redis instance to be shared.
Expand Down