Skip to content

Commit 089b5e4

Browse files
committed
add base for new caching approach, deprecate legacy
1 parent 6d8eca9 commit 089b5e4

31 files changed

+1089
-65
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
6+
namespace Kontent.Ai.Delivery.Abstractions;
7+
8+
/// <summary>
9+
/// Manages caching of Delivery API responses with automatic dependency tracking and invalidation.
10+
/// Implementations should provide thread-safe operations suitable for concurrent access.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// This interface follows modern .NET caching patterns, separating retrieval (<see cref="GetAsync{T}"/>),
15+
/// storage (<see cref="SetAsync{T}"/>), and invalidation (<see cref="InvalidateAsync"/>) concerns.
16+
/// </para>
17+
/// <para>
18+
/// The dependency tracking system enables automatic cache invalidation when content changes.
19+
/// When storing a cache entry, you specify which content items, assets, or taxonomies it depends on.
20+
/// Later, calling <see cref="InvalidateAsync"/> with any of those dependency keys will invalidate
21+
/// all cache entries that reference them.
22+
/// </para>
23+
/// <para>
24+
/// Dependency key format conventions:
25+
/// <list type="bullet">
26+
/// <item><description>Content items: <c>item_{codename}</c> (e.g., "item_hero")</description></item>
27+
/// <item><description>Assets: <c>asset_{guid}</c> (e.g., "asset_a5e1c4b2-...")</description></item>
28+
/// <item><description>Taxonomies: <c>taxonomy_{group}</c> (e.g., "taxonomy_categories")</description></item>
29+
/// </list>
30+
/// </para>
31+
/// </remarks>
32+
public interface IDeliveryCacheManager
33+
{
34+
/// <summary>
35+
/// Attempts to retrieve a cached value by its key.
36+
/// </summary>
37+
/// <typeparam name="T">The type of the cached value.</typeparam>
38+
/// <param name="cacheKey">The unique key identifying the cache entry.</param>
39+
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
40+
/// <returns>
41+
/// A task representing the asynchronous operation, containing the cached value if found;
42+
/// otherwise, <c>null</c>.
43+
/// </returns>
44+
/// <remarks>
45+
/// This method should return <c>null</c> for cache misses or expired entries, not throw exceptions.
46+
/// Implementations should handle deserialization errors gracefully by treating them as cache misses.
47+
/// </remarks>
48+
Task<T?> GetAsync<T>(string cacheKey, CancellationToken cancellationToken = default) where T : class;
49+
50+
/// <summary>
51+
/// Stores a value in the cache with associated dependency keys for automatic invalidation.
52+
/// </summary>
53+
/// <typeparam name="T">The type of the value to cache.</typeparam>
54+
/// <param name="cacheKey">The unique key under which to store the value. Must not be <c>null</c> or empty.</param>
55+
/// <param name="value">The value to cache. Must not be <c>null</c>.</param>
56+
/// <param name="dependencies">
57+
/// A collection of dependency keys that, when invalidated, will also invalidate this cache entry.
58+
/// Use standardized key formats (see <see cref="IDeliveryCacheManager"/> remarks).
59+
/// Must not be <c>null</c>, but may be empty.
60+
/// </param>
61+
/// <param name="expiration">
62+
/// Optional absolute expiration timespan. If <c>null</c>, the implementation's default expiration is used.
63+
/// </param>
64+
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
65+
/// <returns>A task representing the asynchronous storage operation.</returns>
66+
/// <exception cref="ArgumentNullException">
67+
/// Thrown when <paramref name="cacheKey"/>, <paramref name="value"/>, or <paramref name="dependencies"/> is <c>null</c>.
68+
/// </exception>
69+
/// <exception cref="ArgumentException">
70+
/// Thrown when <paramref name="cacheKey"/> is empty or whitespace.
71+
/// </exception>
72+
/// <remarks>
73+
/// <para>
74+
/// Implementations should create a reverse index mapping each dependency key to all cache entries
75+
/// that reference it, enabling efficient invalidation via <see cref="InvalidateAsync"/>.
76+
/// </para>
77+
/// <para>
78+
/// If the cache write fails, implementations should throw an exception rather than silently fail,
79+
/// allowing the calling code to handle the error appropriately.
80+
/// </para>
81+
/// </remarks>
82+
Task SetAsync<T>(
83+
string cacheKey,
84+
T value,
85+
IEnumerable<string> dependencies,
86+
TimeSpan? expiration = null,
87+
CancellationToken cancellationToken = default) where T : class;
88+
89+
/// <summary>
90+
/// Invalidates all cache entries that depend on the specified dependency keys.
91+
/// </summary>
92+
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
93+
/// <param name="dependencyKeys">
94+
/// One or more dependency keys to invalidate. All cache entries referencing any of these keys
95+
/// will be removed from the cache.
96+
/// </param>
97+
/// <returns>A task representing the asynchronous invalidation operation.</returns>
98+
/// <remarks>
99+
/// <para>
100+
/// This method performs cascade invalidation: if a cache entry depends on any of the specified keys,
101+
/// it will be removed, regardless of what other dependencies it may have.
102+
/// </para>
103+
/// <para>
104+
/// Invalidating a non-existent dependency key should succeed without error (idempotent operation).
105+
/// </para>
106+
/// <para>
107+
/// Example: If a cached items list depends on "item_hero" and "item_author", calling
108+
/// <c>InvalidateAsync(dependencyKeys: "item_hero")</c> will invalidate the entire list,
109+
/// even though "item_author" was not invalidated.
110+
/// </para>
111+
/// </remarks>
112+
Task InvalidateAsync(CancellationToken cancellationToken = default, params string[] dependencyKeys);
113+
}

Kontent.Ai.Delivery.Abstractions/Configuration/DeliveryOptions.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,39 @@ public sealed class DeliveryOptions : IValidatableObject
2121
/// </summary>
2222
public bool EnableResilience { get; set; } = true;
2323

24+
/// <summary>
25+
/// Gets or sets a value that determines if the client uses integrated caching for API responses.
26+
/// When enabled, the client will cache responses and automatically track dependencies for invalidation.
27+
/// </summary>
28+
/// <remarks>
29+
/// <para>
30+
/// When caching is enabled, an implementation of <see cref="IDeliveryCacheManager"/> must be
31+
/// registered in the dependency injection container. If no cache manager is found, requests will
32+
/// fail with a clear error message.
33+
/// </para>
34+
/// <para>
35+
/// To enable caching:
36+
/// <code>
37+
/// services
38+
/// .AddDeliveryClient(options => {
39+
/// options.EnvironmentId = "your-environment-id";
40+
/// options.EnableCaching = true;
41+
/// })
42+
/// .AddDeliveryMemoryCache(); // or AddDeliveryDistributedCache()
43+
/// </code>
44+
/// </para>
45+
/// <para>
46+
/// Caching significantly improves performance by eliminating redundant API calls. The cache
47+
/// automatically tracks dependencies on content items, assets, and taxonomies, allowing
48+
/// precise invalidation when content changes.
49+
/// </para>
50+
/// <para>
51+
/// Default: <c>false</c> (caching disabled).
52+
/// </para>
53+
/// </remarks>
54+
/// <seealso cref="IDeliveryCacheManager"/>
55+
public bool EnableCaching { get; set; } = false;
56+
2457
/// <summary>
2558
/// Gets or sets the format of the Production API endpoint address.
2659
/// </summary>

Kontent.Ai.Delivery.Abstractions/ContentItems/IElementsPostProcessor.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Text.Json;
33
using System.Threading;
44
using System.Threading.Tasks;
5+
using Kontent.Ai.Delivery.Abstractions.ContentItems.Processing;
56

67
namespace Kontent.Ai.Delivery.Abstractions;
78

@@ -17,10 +18,16 @@ public interface IElementsPostProcessor
1718
/// <typeparam name="TModel">Elements model type.</typeparam>
1819
/// <param name="item">The content item to process.</param>
1920
/// <param name="modularContent">Raw modular content dictionary from the API response.</param>
21+
/// <param name="dependencyContext">
22+
/// Optional context for tracking cache dependencies discovered during element hydration.
23+
/// When provided, dependencies on referenced assets, taxonomies, and linked items are tracked.
24+
/// Pass null when caching is disabled to avoid tracking overhead.
25+
/// </param>
2026
/// <param name="cancellationToken">Cancellation token.</param>
2127
Task ProcessAsync<TModel>(
2228
IContentItem<TModel> item,
2329
IReadOnlyDictionary<string, JsonElement>? modularContent,
30+
DependencyTrackingContext? dependencyContext = null,
2431
CancellationToken cancellationToken = default)
2532
where TModel : IElementsModel;
2633
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Kontent.Ai.Delivery.Abstractions.ContentItems.Processing;
5+
6+
/// <summary>
7+
/// Tracks cache dependencies discovered during content item processing.
8+
/// Used internally to build the set of dependency keys that should invalidate
9+
/// a cached API response when the referenced content changes.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// This class is designed to be used during a single API response processing pipeline.
14+
/// As elements are hydrated (rich text, taxonomies, linked items), dependencies are
15+
/// tracked by calling <see cref="TrackItem"/>, <see cref="TrackAsset"/>, or <see cref="TrackTaxonomy"/>.
16+
/// </para>
17+
/// <para>
18+
/// Thread-safety: This class is thread-safe and can be safely accessed from multiple
19+
/// concurrent operations during async processing. However, it's typically used within
20+
/// a single request context where such concurrency is unlikely.
21+
/// </para>
22+
/// <para>
23+
/// Dependency key formats:
24+
/// <list type="bullet">
25+
/// <item><description>Content items: <c>item_{codename}</c></description></item>
26+
/// <item><description>Assets: <c>asset_{guid}</c></description></item>
27+
/// <item><description>Taxonomies: <c>taxonomy_{group_codename}</c></description></item>
28+
/// </list>
29+
/// These formats align with the cache invalidation strategy in <see cref="IDeliveryCacheManager"/>.
30+
/// </para>
31+
/// </remarks>
32+
public sealed class DependencyTrackingContext
33+
{
34+
/// <summary>
35+
/// Prefix for content item dependency keys.
36+
/// </summary>
37+
public const string ItemPrefix = "item_";
38+
39+
/// <summary>
40+
/// Prefix for asset dependency keys.
41+
/// </summary>
42+
public const string AssetPrefix = "asset_";
43+
44+
/// <summary>
45+
/// Prefix for taxonomy group dependency keys.
46+
/// </summary>
47+
public const string TaxonomyPrefix = "taxonomy_";
48+
49+
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
50+
private readonly object _lock = new();
51+
52+
/// <summary>
53+
/// Gets the collected set of dependency keys.
54+
/// </summary>
55+
/// <remarks>
56+
/// <para>
57+
/// Returns an enumerable over the current dependencies using deferred execution.
58+
/// The enumeration is thread-safe (protected by a lock), but the collection may change
59+
/// between enumerations if dependencies are added concurrently.
60+
/// </para>
61+
/// <para>
62+
/// To get a stable snapshot, enumerate into a collection:
63+
/// <code>var snapshot = context.Dependencies.ToList();</code>
64+
/// </para>
65+
/// </remarks>
66+
public IEnumerable<string> Dependencies
67+
{
68+
get
69+
{
70+
lock (_lock)
71+
{
72+
// Use deferred execution to avoid allocation on every access
73+
foreach (var dependency in _dependencies)
74+
{
75+
yield return dependency;
76+
}
77+
}
78+
}
79+
}
80+
81+
/// <summary>
82+
/// Tracks a dependency on a content item by its codename.
83+
/// </summary>
84+
/// <param name="codename">
85+
/// The codename of the content item. If <c>null</c> or empty, the call is ignored.
86+
/// </param>
87+
/// <remarks>
88+
/// Call this method for:
89+
/// <list type="bullet">
90+
/// <item><description>The primary content item(s) in the response</description></item>
91+
/// <item><description>Linked content items (modular content)</description></item>
92+
/// <item><description>Rich text inline content items</description></item>
93+
/// </list>
94+
/// Codenames are case-insensitive and duplicate calls with the same codename are ignored.
95+
/// </remarks>
96+
public void TrackItem(string? codename)
97+
{
98+
if (string.IsNullOrWhiteSpace(codename))
99+
{
100+
return;
101+
}
102+
103+
lock (_lock)
104+
{
105+
_dependencies.Add($"{ItemPrefix}{codename}");
106+
}
107+
}
108+
109+
/// <summary>
110+
/// Tracks a dependency on an asset by its ID.
111+
/// </summary>
112+
/// <param name="assetId">The unique identifier of the asset.</param>
113+
/// <remarks>
114+
/// <para>
115+
/// Call this method for assets referenced in:
116+
/// <list type="bullet">
117+
/// <item><description>Rich text image elements</description></item>
118+
/// <item><description>Asset element values</description></item>
119+
/// </list>
120+
/// </para>
121+
/// <para>
122+
/// Asset IDs are globally unique, so Guid.Empty is a valid value and will be tracked.
123+
/// Duplicate calls with the same asset ID are ignored.
124+
/// </para>
125+
/// </remarks>
126+
public void TrackAsset(Guid assetId)
127+
{
128+
lock (_lock)
129+
{
130+
_dependencies.Add($"{AssetPrefix}{assetId}");
131+
}
132+
}
133+
134+
/// <summary>
135+
/// Tracks a dependency on a taxonomy group by its codename.
136+
/// </summary>
137+
/// <param name="taxonomyGroup">
138+
/// The codename of the taxonomy group. If <c>null</c> or empty, the call is ignored.
139+
/// </param>
140+
/// <remarks>
141+
/// <para>
142+
/// Call this method when processing taxonomy elements to track dependencies on the
143+
/// taxonomy group structure itself, not individual terms.
144+
/// </para>
145+
/// <para>
146+
/// Taxonomy group codenames are case-insensitive and duplicate calls are ignored.
147+
/// </para>
148+
/// <para>
149+
/// Note: This tracks the taxonomy group definition. Changes to individual term names
150+
/// or the term hierarchy will invalidate caches depending on this group.
151+
/// </para>
152+
/// </remarks>
153+
public void TrackTaxonomy(string? taxonomyGroup)
154+
{
155+
if (string.IsNullOrWhiteSpace(taxonomyGroup))
156+
{
157+
return;
158+
}
159+
160+
lock (_lock)
161+
{
162+
_dependencies.Add($"{TaxonomyPrefix}{taxonomyGroup}");
163+
}
164+
}
165+
}

Kontent.Ai.Delivery.Abstractions/IDeliveryCacheManager.cs renamed to Kontent.Ai.Delivery.Abstractions/IDeliveryCacheManager.Old.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ namespace Kontent.Ai.Delivery.Abstractions;
77
/// <summary>
88
/// Cache responses against the Kontent.ai Delivery API.
99
/// </summary>
10-
public interface IDeliveryCacheManager
10+
[Obsolete("This interface is deprecated and will be removed in a future version. Use the new IDeliveryCacheManager interface with Get/Set/Invalidate pattern. See caching-v3.md for migration guidance.", false)]
11+
public interface IDeliveryCacheManagerLegacy
1112
{
1213
/// <summary>
1314
/// Returns the cached data or fetches the data using a factory and caches it before returning.

Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ namespace Kontent.Ai.Delivery.Caching;
1212
/// </remarks>
1313
/// <param name="cacheManager">The cache manager for storing and retrieving cached responses.</param>
1414
/// <param name="deliveryClient">The underlying delivery client.</param>
15-
public class DeliveryClientCache(IDeliveryCacheManager cacheManager, IDeliveryClient deliveryClient) : IDeliveryClient
15+
public class DeliveryClientCache(IDeliveryCacheManagerLegacy cacheManager, IDeliveryClient deliveryClient) : IDeliveryClient
1616
{
1717
private readonly IDeliveryClient _deliveryClient = deliveryClient ?? throw new ArgumentNullException(nameof(deliveryClient));
18-
private readonly IDeliveryCacheManager _deliveryCacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager));
18+
private readonly IDeliveryCacheManagerLegacy _deliveryCacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager));
1919

2020
/// <summary>
2121
/// Returns a query builder for retrieving a single strongly typed content item.

Kontent.Ai.Delivery.Caching/DistributedCacheManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Kontent.Ai.Delivery.Caching;
1313
/// <summary>
1414
/// Cache responses against the Kontent.ai Delivery API.
1515
/// </summary>
16-
internal class DistributedCacheManager : IDeliveryCacheManager
16+
internal class DistributedCacheManager : IDeliveryCacheManagerLegacy
1717
{
1818
private readonly IDistributedCache _distributedCache;
1919
private readonly DeliveryCacheOptions _cacheOptions;

Kontent.Ai.Delivery.Caching/Extensions/ServiceCollectionExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ private static IServiceCollection RegisterDependencies(this IServiceCollection s
3535
switch (cacheType)
3636
{
3737
case CacheTypeEnum.Memory:
38-
services.TryAddSingleton<IDeliveryCacheManager, MemoryCacheManager>();
38+
services.TryAddSingleton<IDeliveryCacheManagerLegacy, MemoryCacheManager>();
3939
services.TryAddSingleton<IMemoryCache, MemoryCache>();
4040
break;
4141

4242
case CacheTypeEnum.Distributed:
43-
services.TryAddSingleton<IDeliveryCacheManager, DistributedCacheManager>();
43+
services.TryAddSingleton<IDeliveryCacheManagerLegacy, DistributedCacheManager>();
4444
services.TryAddSingleton<IDistributedCache, MemoryDistributedCache>();
4545
break;
4646
}

0 commit comments

Comments
 (0)