From b22a917d3e715eddc1010bdee39f89198e4bc8d5 Mon Sep 17 00:00:00 2001 From: Hafiz Adewuyi Date: Tue, 17 Sep 2024 15:49:11 +0300 Subject: [PATCH] Bugfix/remove limit on popular words (#113) * Replace in-memory caching with Redis caching. * Solve cycling problem for the next 100+ years --- Api/Program.cs | 2 - Application/Cache/ICacheService.cs | 39 ------------------- Core/Cache/IRecentIndexesCache.cs | 2 +- Core/Cache/IRecentSearchesCache.cs | 2 +- Core/Cache/{ICache.cs => ISetBasedCache.cs} | 2 +- Core/Cache/ISimpleCache.cs | 8 ++++ Infrastructure/Redis/DependencyInjection.cs | 2 + .../Redis/RedisRecentSearchesCache.cs | 15 ++++--- Infrastructure/Redis/SimpleRedisCache.cs | 32 +++++++++++++++ Infrastructure/Twitter/TwitterService.cs | 12 +++--- 10 files changed, 60 insertions(+), 56 deletions(-) delete mode 100644 Application/Cache/ICacheService.cs rename Core/Cache/{ICache.cs => ISetBasedCache.cs} (86%) create mode 100644 Core/Cache/ISimpleCache.cs create mode 100644 Infrastructure/Redis/SimpleRedisCache.cs diff --git a/Api/Program.cs b/Api/Program.cs index a3d1baf..d8f4f4b 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,5 +1,4 @@ using Api.ExceptionHandler; -using Application.Cache; using Application.Domain; using Application.Events; using Application.Migrator; @@ -126,7 +125,6 @@ services.AddSingleton(); services.AddTwitterClient(configuration); -builder.Services.AddMemoryCache(); builder.Services.SetupHangfire(Guard.Against.NullOrEmpty(configuration.GetRequiredSection("MongoDB:ConnectionString").Value)); builder.Services.SetupRedis(configuration); diff --git a/Application/Cache/ICacheService.cs b/Application/Cache/ICacheService.cs deleted file mode 100644 index 8cc97c4..0000000 --- a/Application/Cache/ICacheService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.Cache -{ - public interface ICacheService - { - /// - /// Retrieves a collection of items from the cache. - /// - /// The key under which the items are cached. - /// A task that represents the asynchronous operation. The task result contains a collection of items. - Task> GetAsync(string key); - - /// - /// Adds an item to the cache and updates its recency. - /// - /// The key under which the item is cached. - /// The item to be added to the cache. - /// A task that represents the asynchronous operation. - Task StackAsync(string key, T item); - - /// - /// Removes an item from the cache. - /// - /// The key of the item to be removed. - /// A task that represents the asynchronous operation. The task result contains a boolean indicating whether the removal was successful. - Task RemoveAsync(string key, string searchTerm); - - /// - /// Retrieves the most popular items based on their frequency. - /// - /// A task that represents the asynchronous operation. The task result contains a collection of the most popular items. - Task> GetMostPopularAsync(string key); - } -} diff --git a/Core/Cache/IRecentIndexesCache.cs b/Core/Cache/IRecentIndexesCache.cs index 18535f4..f533cd7 100644 --- a/Core/Cache/IRecentIndexesCache.cs +++ b/Core/Cache/IRecentIndexesCache.cs @@ -1,6 +1,6 @@ namespace Core.Cache { - public interface IRecentIndexesCache : ICache + public interface IRecentIndexesCache : ISetBasedCache { } } \ No newline at end of file diff --git a/Core/Cache/IRecentSearchesCache.cs b/Core/Cache/IRecentSearchesCache.cs index b601774..73d9bd3 100644 --- a/Core/Cache/IRecentSearchesCache.cs +++ b/Core/Cache/IRecentSearchesCache.cs @@ -1,6 +1,6 @@ namespace Core.Cache { - public interface IRecentSearchesCache : ICache + public interface IRecentSearchesCache : ISetBasedCache { Task> GetMostPopular(); } diff --git a/Core/Cache/ICache.cs b/Core/Cache/ISetBasedCache.cs similarity index 86% rename from Core/Cache/ICache.cs rename to Core/Cache/ISetBasedCache.cs index d86b3e1..581739e 100644 --- a/Core/Cache/ICache.cs +++ b/Core/Cache/ISetBasedCache.cs @@ -6,7 +6,7 @@ namespace Core.Cache { - public interface ICache + public interface ISetBasedCache { Task> Get(); Task Stack(T item); diff --git a/Core/Cache/ISimpleCache.cs b/Core/Cache/ISimpleCache.cs new file mode 100644 index 0000000..d943fb8 --- /dev/null +++ b/Core/Cache/ISimpleCache.cs @@ -0,0 +1,8 @@ +namespace Core.Cache +{ + public interface ISimpleCache + { + Task SetAsync(string key, T value, TimeSpan? expiry = null); + Task GetAsync(string key); + } +} diff --git a/Infrastructure/Redis/DependencyInjection.cs b/Infrastructure/Redis/DependencyInjection.cs index 9dc6fd9..2e7c906 100644 --- a/Infrastructure/Redis/DependencyInjection.cs +++ b/Infrastructure/Redis/DependencyInjection.cs @@ -1,4 +1,5 @@ using Ardalis.GuardClauses; +using Core.Cache; using Infrastructure.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +16,7 @@ public static IServiceCollection SetupRedis(this IServiceCollection services, IC services.Configure(configuration.GetRequiredSection(SectionName)); var redisConnectionString = Guard.Against.NullOrEmpty(configuration.GetConnectionString(SectionName)); services.AddSingleton(ConnectionMultiplexer.Connect(redisConnectionString)); + services.AddSingleton(); return services; } } diff --git a/Infrastructure/Redis/RedisRecentSearchesCache.cs b/Infrastructure/Redis/RedisRecentSearchesCache.cs index e08f764..e34ad2b 100644 --- a/Infrastructure/Redis/RedisRecentSearchesCache.cs +++ b/Infrastructure/Redis/RedisRecentSearchesCache.cs @@ -11,9 +11,14 @@ public class RedisRecentSearchesCache( { private const string RecentSearchesKey = "recent_searches"; private const string PopularSearchesKey = "popular_searches"; + private static readonly DateTime StartDate; private const int MaxItemsToReturn = 5; private const int MaxRecentSearches = 10; - private const int MaxPopularSearches = 1000; // Use a large number to ensure that items have time to get promoted. + + static RedisRecentSearchesCache() + { + StartDate = new(2024, 9, 17); // Do not change + } public async Task> Get() { @@ -46,8 +51,7 @@ public async Task Stack(string item) // TODO: Do a periodic caching, like daily where the most popular items from the previous period are brought forward into the next day var currentScore = (await _cache.SortedSetScoreAsync(PopularSearchesKey, item)) ?? 0; - _ = transaction.SortedSetAddAsync(PopularSearchesKey, item, (int)currentScore + 1 + GetNormalizedTimestamp()); - _ = transaction.SortedSetRemoveRangeByRankAsync(PopularSearchesKey, 0, -(MaxPopularSearches + 1)); + _ = transaction.SortedSetAddAsync(PopularSearchesKey, item, (int)++currentScore + GetNormalizedTimestamp()); // Execute the transaction bool committed = await transaction.ExecuteAsync(); @@ -59,9 +63,8 @@ public async Task Stack(string item) static double GetNormalizedTimestamp() { - // This can be improved by addressing the time-cycle reset problem. - long unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - return (unixTimestamp % 1000000) / 1000000.0; + TimeSpan timeSinceStartDate = DateTime.Now - StartDate; + return timeSinceStartDate.TotalSeconds / 10_000_000_000; // It will take over 100 years for this value to grow to 1. } } } diff --git a/Infrastructure/Redis/SimpleRedisCache.cs b/Infrastructure/Redis/SimpleRedisCache.cs new file mode 100644 index 0000000..e1e3cf1 --- /dev/null +++ b/Infrastructure/Redis/SimpleRedisCache.cs @@ -0,0 +1,32 @@ +using Ardalis.GuardClauses; +using Core.Cache; +using Infrastructure.Configuration; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +namespace Infrastructure.Redis +{ + public class SimpleRedisCache(IConnectionMultiplexer connectionMultiplexer, IOptions redisConfig) : + RedisCache(connectionMultiplexer, redisConfig), ISimpleCache + { + public async Task GetAsync(string key) + { + RedisValue theValue = await _cache.StringGetAsync(key); + + return theValue.IsNullOrEmpty ? default : ConvertToType(theValue); + } + + private static T ConvertToType(RedisValue value) + { + if(typeof(T) == typeof(DateTimeOffset)) + { + return (T)(object)DateTimeOffset.Parse(value.ToString()); + } + return (T)Convert.ChangeType(value.ToString(), typeof(T)); + } + + public async Task SetAsync(string key, T value, TimeSpan? expiry = null) + { + await _cache.StringSetAsync(key, Guard.Against.Null(value)!.ToString(), expiry); + } + } +} diff --git a/Infrastructure/Twitter/TwitterService.cs b/Infrastructure/Twitter/TwitterService.cs index 5dc8057..0677d41 100644 --- a/Infrastructure/Twitter/TwitterService.cs +++ b/Infrastructure/Twitter/TwitterService.cs @@ -2,9 +2,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Diagnostics; -using Microsoft.Extensions.Caching.Memory; using Hangfire; using Infrastructure.Configuration; +using Core.Cache; namespace Infrastructure.Twitter { @@ -12,13 +12,13 @@ public class TwitterService( ITwitterClientV2 twitterApiClient, ILogger logger, IOptions twitterConfig, - IMemoryCache cache, + ISimpleCache cache, IBackgroundJobClientV2 backgroundJobClient) : ITwitterService { private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; private readonly ILogger _logger = logger; private readonly TwitterConfig _twitterConfig = twitterConfig.Value; - private readonly IMemoryCache _memoryCache = cache; + private readonly ISimpleCache _simpleCache = cache; private readonly IBackgroundJobClientV2 _backgroundJobClient = backgroundJobClient; private static readonly SemaphoreSlim _semaphore; private const string LastTweetPublishedKey = "LastTweetPublished"; @@ -48,10 +48,10 @@ private async Task PostTweetAsync(string tweetText, CancellationToken cancellati await _semaphore.WaitAsync(cancellationToken); // We want to be scheduling only one tweet at a time. try { - var foundLastPublished = _memoryCache.TryGetValue(LastTweetPublishedKey, out DateTimeOffset lastTweetPublished); + var lastTweetPublished = await _simpleCache.GetAsync(LastTweetPublishedKey); var nextTweetTime = lastTweetPublished.AddSeconds(_twitterConfig.TweetIntervalSeconds); - if (foundLastPublished && nextTweetTime > DateTimeOffset.Now) + if (lastTweetPublished != default && nextTweetTime > DateTimeOffset.Now) { _backgroundJobClient.Schedule(() => SendTweetAsync(tweetText), nextTweetTime); } @@ -61,7 +61,7 @@ private async Task PostTweetAsync(string tweetText, CancellationToken cancellati _backgroundJobClient.Enqueue(() => SendTweetAsync(tweetText)); } - _memoryCache.Set(LastTweetPublishedKey, nextTweetTime); + await _simpleCache.SetAsync(LastTweetPublishedKey, nextTweetTime); } finally {