diff --git a/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/CacheInvalidationService.cs b/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/CacheInvalidationService.cs new file mode 100644 index 0000000..135eb3f --- /dev/null +++ b/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/CacheInvalidationService.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Kontent.Ai.Delivery.Abstractions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Kontent.Ai.Boilerplate.CacheInvalidation; + +internal class CacheInvalidationService : IHostedService +{ + private readonly IDeliveryCacheManager _cacheManager; + private readonly IOptions _options; + private string? _continuationToken; + private readonly HttpClient _client; + private Timer? _timer; + + + public CacheInvalidationService(IDeliveryCacheManager cacheManager, IOptions options) + { + _cacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _client = new HttpClient(); + _continuationToken = CacheInvalidationServiceHelper + .CheckChangeFeed(client: _client, options: options, continuationToken: _continuationToken).Result.Item2; + } + + + public Task StartAsync(CancellationToken cancellationToken) + { + _timer = new Timer(TryCacheInvalidation, null, TimeSpan.Zero, + TimeSpan.FromMinutes(5)); + + return Task.CompletedTask; + } + + + public Task StopAsync(CancellationToken cancellationToken) + { + _timer?.Dispose(); + return Task.CompletedTask; + } + + + private async void TryCacheInvalidation(object? state) + { + IEnumerable? changeFeed; + do + { + (changeFeed, var continuationToken) = await CacheInvalidationServiceHelper.CheckChangeFeed(client: _client, + options: _options, continuationToken: _continuationToken); + if (continuationToken != null && continuationToken != _continuationToken) + _continuationToken = continuationToken; + if (changeFeed != null) + await CacheInvalidationServiceHelper.InvalidateCache(itemsChanged: changeFeed, + cacheManager: _cacheManager); + } while (changeFeed != null); + } +} \ No newline at end of file diff --git a/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/CacheInvalidationServiceHelper.cs b/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/CacheInvalidationServiceHelper.cs new file mode 100644 index 0000000..bdc76f2 --- /dev/null +++ b/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/CacheInvalidationServiceHelper.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Kontent.Ai.Delivery.Abstractions; +using Kontent.Ai.Delivery.Caching; +using Microsoft.Extensions.Options; + +namespace Kontent.Ai.Boilerplate.CacheInvalidation; + +internal static class CacheInvalidationServiceHelper +{ + internal static async Task?, string?>> CheckChangeFeed( + IOptions options, string? continuationToken, HttpClient client) + { + var changeFeedResponse = await client.SendAsync( + new HttpRequestMessage(method: HttpMethod.Get, + requestUri: + $"{options.Value.ProductionEndpoint}/{options.Value.ProjectId}/change-feed") + { Headers = { { HeaderNames.Continuation, continuationToken } } }); + var changeFeedItems = changeFeedResponse.StatusCode == HttpStatusCode.OK + ? await JsonSerializer.DeserializeAsync>( + await changeFeedResponse.Content.ReadAsStreamAsync()) + : null; + + return new Tuple?, string?>(changeFeedItems, + changeFeedResponse.Headers.GetValues(HeaderNames.Continuation).FirstOrDefault()); + } + + internal static async Task InvalidateCache(IEnumerable itemsChanged, + IDeliveryCacheManager cacheManager) + { + var dependencies = new HashSet(); + { + foreach (var item in itemsChanged) + { + dependencies.Add(CacheHelpers.GetItemDependencyKey(item.Codename)); + } + + dependencies.Add(CacheHelpers.GetItemsDependencyKey()); + } + + foreach (var dependency in dependencies) + { + await cacheManager.InvalidateDependencyAsync(dependency); + } + } +} \ No newline at end of file diff --git a/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/ChangeFeedResponseItem.cs b/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/ChangeFeedResponseItem.cs new file mode 100644 index 0000000..8d2dc4f --- /dev/null +++ b/src/content/Kontent.Ai.Boilerplate/CacheInvalidation/ChangeFeedResponseItem.cs @@ -0,0 +1,5 @@ +using System.Text.Json.Serialization; + +namespace Kontent.Ai.Boilerplate.CacheInvalidation; + +internal record ChangeFeedResponseItem([property: JsonPropertyName("codename")]string Codename); diff --git a/src/content/Kontent.Ai.Boilerplate/Constants.cs b/src/content/Kontent.Ai.Boilerplate/Constants.cs new file mode 100644 index 0000000..b1487a0 --- /dev/null +++ b/src/content/Kontent.Ai.Boilerplate/Constants.cs @@ -0,0 +1,6 @@ +namespace Kontent.Ai.Boilerplate; + +public static class HeaderNames +{ + public const string Continuation = "X-Continuation"; +} \ No newline at end of file diff --git a/src/content/Kontent.Ai.Boilerplate/Startup.cs b/src/content/Kontent.Ai.Boilerplate/Startup.cs index 07453fd..b21df63 100644 --- a/src/content/Kontent.Ai.Boilerplate/Startup.cs +++ b/src/content/Kontent.Ai.Boilerplate/Startup.cs @@ -13,6 +13,7 @@ using Kontent.Ai.Delivery.Abstractions; using Kontent.Ai.Delivery.Extensions; using Kontent.Ai.AspNetCore.Webhooks; +using Kontent.Ai.Boilerplate.CacheInvalidation; namespace Kontent.Ai.Boilerplate { @@ -37,7 +38,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddDeliveryClient(Configuration); - + services.AddHostedService(); // Use cached client decorator services.AddDeliveryClientCache(new DeliveryCacheOptions() { @@ -74,7 +75,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) // Register webhook-based cache invalidation controller app.UseWebhookSignatureValidator(context => context.Request.Path.StartsWithSegments("/webhooks/webhooks", StringComparison.OrdinalIgnoreCase), Configuration.GetSection(nameof(WebhookOptions))); - + app.UseEndpoints(endpoints => { endpoints.MapControllerRoute(