Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .slopwatch/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"ruleId": "SW004",
"pattern": "**/Caching/RedisCacheIntegrationTests.cs",
"justification": "Same reason as HybridCacheCoherenceTests, against real Redis: the backplane notification is asynchronous and has no awaitable completion, so the assertion polls to a deadline and exits as soon as the invalidation lands."
},
{
"ruleId": "SW004",
"pattern": "**/Caching/MemoryCacheManagerTests.cs",
"justification": "One test pins how long an invalidation is remembered, which is the lifetime of FusionCache's tag data. That lifetime is observable only by letting it lapse: any read while it is live is what applies the invalidation and removes the entry, so the test cannot poll. It shortens the lifetime through the consumer hook and waits past it once."
}
],
"globalSuppressions": []
Expand Down
629 changes: 629 additions & 0 deletions docs/delivery-caching-analysis.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions src/delivery/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ Entries before the move to this monorepo were imported from the GitHub Releases

### Added

- **The cache manager is reachable without knowing the default client's key, and on a standalone client at all.** The manager was registered only as a keyed service under the client's name, which for the default client is an internal constant no document names - so a webhook handler for the common `AddDeliveryClient(delivery => delivery.UseMemoryCache())` setup had no route to `InvalidateAsync` but a guessed string. It now resolves unkeyed as well, `GetRequiredService<IDeliveryCacheManager>()`, the way the client itself does; named clients keep their keyed registration. A client built with `DeliveryClient.Create` owns its container and exposed nothing inside it, so its cache could be filled but never invalidated; `DeliveryClient.CacheManager` is the manager it was registered with, or `null` when it caches nothing.

- **`DeliveryCacheDependencies` composes dependency keys, and invalidation matches them case-insensitively.** A webhook handler had to write `"item_" + codename` by hand, and the SDK compared the result ordinally against tags it had composed lower-case, so a key copied from a payload in another casing evicted nothing and said nothing. `ForItem`, `ForType`, `ForTaxonomy` and `ForAsset` now sit next to the scope constants and produce the exact strings the SDK tags with, trimmed and lower-cased; `InvalidateAsync` normalizes what it is given the same way.

- **`CacheResult<T>.IsStale` says whether fail-safe served a stale copy.** It is what turns a cache hit into `ResponseSource.FailSafe`, and it belongs with the value: the cache is the only component that knows what it handed back, and the call it handed it to is the only one the answer applies to. The SDK's managers record it per call, on the async context of the read, which replaces a process-wide table of keys in fail-safe that any concurrent read of the same key could overwrite. A custom manager sets it on the results it returns.

- **`ExecuteAsync(continuationToken)` on the feed and used-in queries**, resuming a walk from a persisted cursor. Added as an overload rather than a parameter on the existing method, so `ExecuteAsync(cancellationToken)` keeps compiling.

- **`ContinuationToken` on `IDeliveryItemsFeedResponse` and `IDeliveryItemsFeedResponse<T>`**, making the feed's result-based route resumable across a process restart, which `FetchNextPageAsync` cannot be. `HasNextPage` is unchanged and remains equivalent to the token being present.
Expand Down Expand Up @@ -122,12 +128,28 @@ Entries before the move to this monorepo were imported from the GitHub Releases

### Changed

- **Distributed cache keys carry a format version, and lose the `cache:` and `dep:` segments.** A Redis outlives a deployment, so an entry written by one version of the SDK is read by the next; with nothing in the key to say what shape it has, a payload change between releases made every stale hit throw `FusionCacheSerializationException` until the entry expired. A hybrid client's keys are now `{KeyPrefix}:{EnvironmentId}:v1:{key}`, the version to be bumped whenever a cached type or FusionCache's own entry format changes, so an upgraded node misses on old entries instead. The `cache:` and `dep:` segments are gone with it - FusionCache keeps tag data under keys of its own, so the two could never collide - and the tag data is now stored under the client's prefix as well. Entries written by a previous prerelease are not read; they expire on their own.

- **The SDK's cache logs under one category, `Kontent.Ai.Delivery.Caching.FusionCacheManager`.** `MemoryCacheManager` and `HybridCacheManager` were two forwarding classes over one implementation, distinguishable only by their logger categories; they are folded into it. The public surface is untouched - both were internal - and `IDeliveryCacheManager.StorageMode` still says which kind a manager is.

- **Refit moves to 15.2.0, and the `Microsoft.Extensions.*` packages to 10.0.11 with it.** Refit 15 adds a keyed registration for source-generated clients, which is the one registration the SDK had to hand-roll and now uses instead; nothing else in the release touches what the SDK uses, and the whole test suite passes on it unchanged. The package's Refit dependency floor moves accordingly, so an application that pins Refit 14 alongside this package must move to 15 as well.

- **`Microsoft.Extensions.Configuration` and `Microsoft.Extensions.Configuration.Binder` are no longer direct dependencies, nor is `Microsoft.Extensions.Primitives`.** Nothing in the package used them. `Microsoft.Extensions.Options.ConfigurationExtensions` stays and brings the binder with it, so `Options.BindConfiguration` on the builder works unchanged; an application that built its own `ConfigurationBuilder` on the package's transitive reference must reference `Microsoft.Extensions.Configuration` itself.

### Fixed

- **Fail-safe no longer serves content the API says is gone.** With `IsFailSafeEnabled`, a cached item that a webhook invalidated and the API then answered `404` for was served as a success with `ResponseSource.FailSafe` for up to `FailSafeMaxDuration` - a day by default - so unpublishing did not take effect. The cache could not tell an outage from an answer: every failed fetch reached it as the same "nothing to cache". Now a fetch that got no response, or a status the SDK's own pipeline retries (`408`, `429`, `5xx`), is the outage fail-safe exists for and a stale copy may be served; any other answer is final, the stale copy is dropped with it, and the failure is returned. The `IDeliveryCacheManager` factory contract says the same: return `null` when the origin has no value, throw when it could not be reached.

- **A distributed-cache outage degrades the cache instead of failing every query.** With `UseHybridCache`, a Redis that could not be reached threw `FusionCacheDistributedCacheException` out of every cached query - the one place in the SDK where a transport problem was an exception rather than a result, and fail-safe did not help because the failure came before the factory ran. The distributed tier is now worked around: the memory tier or the origin answers, a two-second circuit breaker keeps a dead Redis from being retried on every request, and FusionCache re-syncs the tier when it is back. FusionCache's own diagnostics were also silent, its logger never having been wired; they now log under `ZiggyCreatures.Caching.Fusion.FusionCache` whenever logging is registered, so a worked-around outage, a failed backplane publish or a background refresh that threw is visible.

- **An invalidation is no longer forgotten after thirty seconds.** `InvalidateAsync` records a tag-expiration entry that the next read of each tagged entry checks against, and the SDK stored that entry with the same options as a cached value - options that named no duration, so FusionCache's thirty-second default applied. An entry not read within thirty seconds of the webhook that invalidated it was served again afterwards, for the rest of its own expiration, as if the webhook had never arrived. The tag data is now stored with FusionCache's tag options, whose duration is ten days by default and is adjustable through `ConfigureFusionCache(f => f.TagsDefaultEntryOptions.Duration = …)`; set it above your longest expiration, per-query overrides included. `PurgeAsync` uses the same options.

- **`ConfigureFusionCache` reaches the SDK's reads and writes.** Whatever the callback set on `DefaultEntryOptions` - the documented example is `AllowBackgroundBackplaneOperations = true` - applied to nothing, because every SDK operation passed entry options it had built itself. The consumer's `DefaultEntryOptions` is now the starting point of those options, so a `Size`, a distributed-cache timeout or the background-operation flags set there take effect. What the SDK decides stays decided: the duration, fail-safe, jitter and eager-refresh policy come from `DeliveryCacheOptions`, serialization failures are thrown, distributed-cache and backplane failures are not.

- **A purge no longer empties every other client sharing the store.** `PurgeAsync` is FusionCache's `Clear`, which records a purge as a marker key of its own, and that key carried none of the SDK's prefix. Every client sharing the `IMemoryCache` or the Redis read the same marker, so purging one tenant's cache after a content-model change sent the others back to the origin too - the setup the caching guide recommends for multi-tenant applications. The SDK now hands its prefix to FusionCache as its `CacheKeyPrefix`, so every key FusionCache stores for a client carries it, its own markers included, and a purge reaches only the client that asked for it.

- **A size-limited memory cache no longer refuses every entry.** `services.AddMemoryCache(o => o.SizeLimit = …)`, which the caching guide recommended, made every write throw `Cache entry must specify a value for Size when SizeLimit is set`, because the SDK declared no size. Every entry it writes now counts as one unit, tag entries included, so a limit bounds the number of cached responses.

- **Resolved rich text is encoded by one encoder throughout.** Text nodes used a Unicode-preserving encoder while attribute values and an inline image's `alt` used `HtmlEncoder.Default`, so the same character survived in one position and was escaped in the other — `<p>café</p>` next to `alt="caf&#xE9;"` in a single document. Both now use the Unicode-preserving encoder. HTML-reserved characters are escaped exactly as before; what changes is that non-ASCII characters in the Basic Multilingual Plane now appear literally in attribute values too. Output that pins the old numeric references character-for-character will differ; rendered output does not.

The comment describing that encoder claimed it preserved emojis. It does not: `UnicodeRanges.All` is the Basic Multilingual Plane, and emoji live in a supplementary plane, so they were and remain numeric references. The comment now says what the code does.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public sealed class CacheEntry`1<T> : IEquatable<CacheEntry<T>>
public sealed class CacheResult`1<T> : IEquatable<CacheResult<T>>
.ctor(T Value, IReadOnlyList<String> DependencyKeys)
Boolean FromFactory { get; init; }
Boolean IsStale { get; init; }
IReadOnlyList<String> DependencyKeys { get; init; }
T Value { get; init; }
Boolean Equals(CacheResult<T>? other)
Expand All @@ -39,6 +40,10 @@ public static class DeliveryCacheDependencies
const String ItemsListScope = scope_items_list
const String TaxonomiesListScope = scope_taxonomies_list
const String TypesListScope = scope_types_list
static String ForAsset(Guid id)
static String ForItem(String codename)
static String ForTaxonomy(String codename)
static String ForType(String codename)

// Kontent.Ai.Delivery.Abstractions
public sealed class DeliveryCacheOptions
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Kontent.Ai.Delivery.Abstractions.Tests.Caching;

public class DeliveryCacheDependenciesTests
{
[Fact]
public void ForItem_ComposesTheKeyTheSdkTagsWith()
=> Assert.Equal("item_article", DeliveryCacheDependencies.ForItem("article"));

[Fact]
public void ForType_ComposesTheKeyTheSdkTagsWith()
=> Assert.Equal("type_article", DeliveryCacheDependencies.ForType("article"));

[Fact]
public void ForTaxonomy_ComposesTheKeyTheSdkTagsWith()
=> Assert.Equal("taxonomy_categories", DeliveryCacheDependencies.ForTaxonomy("categories"));

[Fact]
public void ForAsset_ComposesTheKeyTheSdkTagsWith()
{
var id = Guid.Parse("A5E1C4B2-1234-5678-9ABC-DEF012345678");

Assert.Equal("asset_a5e1c4b2-1234-5678-9abc-def012345678", DeliveryCacheDependencies.ForAsset(id));
}

[Theory]
[InlineData("Article", "item_article")]
[InlineData(" article ", "item_article")]
[InlineData("ARTICLE", "item_article")]
public void ForItem_NormalizesTheCodename(string codename, string expected)
=> Assert.Equal(expected, DeliveryCacheDependencies.ForItem(codename));

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ForItem_RejectsAMissingCodename(string? codename)
=> Assert.ThrowsAny<ArgumentException>(() => DeliveryCacheDependencies.ForItem(codename!));
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public void TrackItemType_ValidCodename_RecordsTypeDependency(string typeCodenam
ctx.TrackItemType(typeCodename);

Assert.Single(ctx.Dependencies);
Assert.Contains($"type_{typeCodename}", ctx.Dependencies);
Assert.Contains($"type_{typeCodename.ToLowerInvariant()}", ctx.Dependencies);
}

[Theory]
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace Kontent.Ai.Delivery.Abstractions;
/// Use standardized key formats (see <see cref="IDeliveryCacheManager"/> remarks).
/// </param>
/// <remarks>
/// Return <c>null</c> from the factory to signal "don't cache" (e.g., on API failure).
/// Return <c>null</c> from the factory when the origin has no value for the key - an item that does not
/// exist, or is no longer published. Throw when the origin could not be reached, so that a cache manager
/// with fail-safe can serve a stale copy for the outage but never for the answer.
/// </remarks>
public sealed record CacheEntry<T>(T Value, IEnumerable<string> Dependencies) where T : class;
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ public sealed record CacheResult<T>(T Value, IReadOnlyList<string> DependencyKey
/// which value it handed back.
/// </remarks>
public bool FromFactory { get; init; }

/// <summary>
/// Whether the value is a stale copy served by fail-safe, because the origin could not be reached
/// during this call or shortly before it. Only the cache knows: the factory either failed or was
/// not run, and neither says which value came back.
/// </summary>
public bool IsStale { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
namespace Kontent.Ai.Delivery.Abstractions;

/// <summary>
/// Defines standard synthetic dependency keys used by the SDK cache invalidation system.
/// The dependency keys the SDK tags cached responses with, for <see cref="IDeliveryCacheManager.InvalidateAsync"/>.
/// The scope constants cover every listing of a kind; the methods compose the key for one entity the way
/// the SDK does, so a webhook handler and the cache agree on the exact string.
/// </summary>
public static class DeliveryCacheDependencies
{
Expand All @@ -22,4 +24,37 @@ public static class DeliveryCacheDependencies
/// Invalidating this key clears all cached taxonomy listing queries for the current cache namespace.
/// </summary>
public const string TaxonomiesListScope = "scope_taxonomies_list";

/// <summary>
/// The key of a content item: <c>item_{codename}</c>. Every cached response that contains the item -
/// as the subject, in a listing, or through modular content - carries it.
/// </summary>
public static string ForItem(string codename) => $"item_{Normalize(codename)}";

/// <summary>
/// The key of a content type: <c>type_{codename}</c>. The cached type definition carries it, and so
/// does every cached response containing an item of that type.
/// </summary>
public static string ForType(string codename) => $"type_{Normalize(codename)}";

/// <summary>
/// The key of a taxonomy group: <c>taxonomy_{codename}</c>. The cached group carries it, and so does
/// every cached response whose items have a taxonomy element drawing from it.
/// </summary>
public static string ForTaxonomy(string codename) => $"taxonomy_{Normalize(codename)}";

/// <summary>
/// The key of an asset: <c>asset_{id}</c>. Every cached response whose items reference the asset, in
/// an asset element or as a rich text image, carries it.
/// </summary>
public static string ForAsset(Guid id) => $"asset_{id:D}";

// Codenames are lower-case by construction and the SDK compares keys ordinally, so a key composed
// from a differently-cased copy of one - a webhook payload, a hand-written constant - is normalized
// here rather than silently matching nothing.
private static string Normalize(string codename)
{
ArgumentException.ThrowIfNullOrWhiteSpace(codename);
return codename.Trim().ToLowerInvariant();
}
}
Loading