Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5fd212d
rebase hc-tags work from dev (easier to re-branch due to drift)
mgravell Jan 9, 2025
9abc346
detect malformed unicode in tags/keys
mgravell Jan 10, 2025
886a413
avoid try/finally in unicode validation step
mgravell Jan 10, 2025
d8cc8e2
make build happy
mgravell Jan 10, 2025
c3cfd2f
happier
mgravell Jan 10, 2025
1b99172
Merge branch 'main' into marc/hc-tags3
mgravell Jan 28, 2025
0478a5d
Merge branch 'main' into marc/hc-tags3
mgravell Jan 28, 2025
9a5e4dc
normalize and test how per-entry vs global options are inherited
mgravell Jan 28, 2025
5d72360
Merge branch 'marc/hc-tags3' of https://github.com/mgravell/extension…
mgravell Jan 28, 2025
6a278fa
add loggine of rejected data
mgravell Jan 29, 2025
654173b
event log for tag invalidations
mgravell Jan 29, 2025
47a4123
make the CI overlord happy
mgravell Jan 29, 2025
c60048e
rebase hc-tags work from dev (easier to re-branch due to drift)
mgravell Jan 9, 2025
6664698
detect malformed unicode in tags/keys
mgravell Jan 10, 2025
75defc2
avoid try/finally in unicode validation step
mgravell Jan 10, 2025
ba433e5
make build happy
mgravell Jan 10, 2025
138946a
happier
mgravell Jan 10, 2025
f78e41d
normalize and test how per-entry vs global options are inherited
mgravell Jan 28, 2025
466b369
add loggine of rejected data
mgravell Jan 29, 2025
803003f
event log for tag invalidations
mgravell Jan 29, 2025
1b89029
make the CI overlord happy
mgravell Jan 29, 2025
33279db
Merge branch 'marc/hc-tags3' of https://github.com/mgravell/extension…
mgravell Jan 29, 2025
b761e50
add event-source into more tests (proves logging-related code branche…
mgravell Jan 30, 2025
11c7efd
add inbuilt type serializer test
mgravell Jan 30, 2025
16eff9e
add license header
mgravell Jan 30, 2025
437e2fa
improving code coverage
mgravell Jan 30, 2025
a5b816e
incorporate PR feedback re thread-static array
mgravell Jan 30, 2025
e470ef7
make the robots happy
mgravell Jan 30, 2025
a20dc13
Update src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Tag…
mgravell Jan 30, 2025
98dfbd6
tag-based invalidate: pass multiple tags, to ensure code paths
mgravell Jan 30, 2025
8b5155e
Merge branch 'marc/hc-tags3' of https://github.com/mgravell/extension…
mgravell Jan 30, 2025
6c7870b
Merge branch 'main' into marc/hc-tags3
mgravell Feb 3, 2025
07e7a39
more coverage
mgravell Feb 3, 2025
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
Prev Previous commit
Next Next commit
normalize and test how per-entry vs global options are inherited
  • Loading branch information
mgravell committed Jan 28, 2025
commit 9a5e4dc748e9bcbd6f5f076183f70d15e3622cf1
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ internal partial class DefaultHybridCache
private const string TagKeyPrefix = "__MSFT_HCT__";
private static readonly DistributedCacheEntryOptions _tagInvalidationEntryOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(MaxCacheDays) };

private static readonly TimeSpan _defaultTimeout = TimeSpan.FromHours(1);

[SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual sync check")]
[SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual sync check")]
[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Explicit async exception handling")]
Expand Down Expand Up @@ -192,7 +190,7 @@ internal void SetL1<T>(string key, CacheItem<T> value, HybridCacheEntryOptions?
// that actually commits the add - so: if we fault, we don't want to try
// committing a partially configured cache entry
ICacheEntry cacheEntry = _localCache.CreateEntry(key);
cacheEntry.AbsoluteExpirationRelativeToNow = options?.LocalCacheExpiration ?? _defaultLocalCacheExpiration;
cacheEntry.AbsoluteExpirationRelativeToNow = GetL1AbsoluteExpirationRelativeToNow(options);
cacheEntry.Value = value;

if (value.TryGetSize(out var size))
Expand Down Expand Up @@ -221,10 +219,10 @@ private async ValueTask WritePayloadAsync(string key, CacheItem cacheItem, Buffe
var maxLength = HybridCachePayload.GetMaxBytes(key, cacheItem.Tags, payload.Length);
var oversized = ArrayPool<byte>.Shared.Rent(maxLength);

var length = HybridCachePayload.Write(oversized, key, cacheItem.CreationTimestamp, options?.Expiration ?? _defaultTimeout,
var length = HybridCachePayload.Write(oversized, key, cacheItem.CreationTimestamp, GetL2AbsoluteExpirationRelativeToNow(options),
HybridCachePayload.PayloadFlags.None, cacheItem.Tags, payload.AsSequence());

await SetDirectL2Async(key, new(oversized, 0, length, true), GetOptions(options), token).ConfigureAwait(false);
await SetDirectL2Async(key, new(oversized, 0, length, true), GetL2DistributedCacheOptions(options), token).ConfigureAwait(false);

ArrayPool<byte>.Shared.Return(oversized);
}
Expand Down Expand Up @@ -255,12 +253,17 @@ private void ThrowPayloadLengthExceeded(int size) // splitting the exception bit
#if NET8_0_OR_GREATER
[SuppressMessage("Maintainability", "CA1508:Avoid dead conditional code", Justification = "False positive from unsafe accessor")]
#endif
private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options)
private DistributedCacheEntryOptions GetL2DistributedCacheOptions(HybridCacheEntryOptions? options)
{
DistributedCacheEntryOptions? result = null;
if (options is not null && options.Expiration.HasValue && options.Expiration.GetValueOrDefault() != _defaultExpiration)
if (options is not null)
{
result = ToDistributedCacheEntryOptions(options);
var expiration = GetL2AbsoluteExpirationRelativeToNow(options);
if (expiration != _defaultExpiration)
{
// ^^^ avoid creating unnecessary DC options objects if the expiration still matches the default
result = ToDistributedCacheEntryOptions(options);
}
}

return result ?? _defaultDistributedCacheExpiration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal;
[SkipLocalsInit]
internal sealed partial class DefaultHybridCache : HybridCache
{
internal const int DefaultExpirationMinutes = 5;

// reserve non-printable characters from keys, to prevent potential L2 abuse
private static readonly char[] _keyReservedCharacters = Enumerable.Range(0, 32).Select(i => (char)i).ToArray();

Expand Down Expand Up @@ -109,8 +111,8 @@ public DefaultHybridCache(IOptions<HybridCacheOptions> options, IServiceProvider
}

_defaultFlags = (defaultEntryOptions?.Flags ?? HybridCacheEntryFlags.None) | _hardFlags;
_defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5);
_defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1);
_defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(DefaultExpirationMinutes);
_defaultLocalCacheExpiration = GetEffectiveLocalCacheExpiration(defaultEntryOptions) ?? _defaultExpiration;
_defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _defaultExpiration };

#if NET9_0_OR_GREATER
Expand Down Expand Up @@ -209,6 +211,15 @@ public override ValueTask SetAsync<T>(string key, T value, HybridCacheEntryOptio
return new(state.ExecuteDirectAsync(value, static (state, _) => new(state), options)); // note this spans L2 write etc
}

// exposed as internal for testability
internal TimeSpan GetL1AbsoluteExpirationRelativeToNow(HybridCacheEntryOptions? options) => GetEffectiveLocalCacheExpiration(options) ?? _defaultLocalCacheExpiration;

internal TimeSpan GetL2AbsoluteExpirationRelativeToNow(HybridCacheEntryOptions? options) => options?.Expiration ?? _defaultExpiration;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal HybridCacheEntryFlags GetEffectiveFlags(HybridCacheEntryOptions? options)
=> (options?.Flags | _hardFlags) ?? _defaultFlags;

private static ValueTask<T> RunWithoutCacheAsync<TState, T>(HybridCacheEntryFlags flags, TState state,
Func<TState, CancellationToken, ValueTask<T>> underlyingDataCallback,
CancellationToken cancellationToken)
Expand All @@ -217,9 +228,15 @@ private static ValueTask<T> RunWithoutCacheAsync<TState, T>(HybridCacheEntryFlag
? underlyingDataCallback(state, cancellationToken) : default;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private HybridCacheEntryFlags GetEffectiveFlags(HybridCacheEntryOptions? options)
=> (options?.Flags | _hardFlags) ?? _defaultFlags;
private static TimeSpan? GetEffectiveLocalCacheExpiration(HybridCacheEntryOptions? options)
{
// If LocalCacheExpiration is not specified, then use option's Expiration, to keep in sync by default.
// Or in other words: the inheritance of "LocalCacheExpiration : Expiration" in a single object takes
// precedence between the inheritance between per-entry options and global options, and if a caller
// provides a per-entry option with *just* the Expiration specified, then that is assumed to also
// specify the LocalCacheExpiration.
return options is not null ? options.LocalCacheExpiration ?? options.Expiration : null;
}

private bool ValidateKey(string key)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,82 @@ public void SubclassMemoryCacheIsNotIgnored(bool manual)
Assert.NotNull(cache.BackendCache);
}

[Theory]

// first 4 tests; regardless of which options objects are supplied, since nothing specified: defaults are assumed
[InlineData(false, null, null, null, false, null, null, null)]
[InlineData(true, null, null, null, false, null, null, null)]
[InlineData(false, null, null, null, true, null, null, null)]
[InlineData(true, null, null, null, true, null, null, null)]

// flags; per-item wins, without merge
[InlineData(false, null, null, null, true, null, null, HybridCacheEntryFlags.None)]
[InlineData(false, null, null, null, true, null, null, HybridCacheEntryFlags.DisableLocalCacheRead, null, null, HybridCacheEntryFlags.DisableLocalCacheRead)]
[InlineData(true, null, null, HybridCacheEntryFlags.None, true, null, null, HybridCacheEntryFlags.DisableLocalCacheRead, null, null, HybridCacheEntryFlags.DisableLocalCacheRead)]
[InlineData(true, null, null, HybridCacheEntryFlags.DisableLocalCacheWrite, true, null, null, HybridCacheEntryFlags.DisableLocalCacheRead, null, null, HybridCacheEntryFlags.DisableLocalCacheRead)]

// flags; global wins if per-item omits, or no per-item flags
[InlineData(true, null, null, HybridCacheEntryFlags.DisableLocalCacheWrite, true, null, null, null, null, null, HybridCacheEntryFlags.DisableLocalCacheWrite)]
[InlineData(true, null, null, HybridCacheEntryFlags.DisableLocalCacheWrite, false, null, null, null, null, null, HybridCacheEntryFlags.DisableLocalCacheWrite)]

// local expiration; per-item wins; expiration bleeds into local expiration (but not the other way around)
[InlineData(false, null, null, null, true, 42, null, null, 42, 42)]
[InlineData(false, null, null, null, true, 42, 43, null, 42, 43)]
[InlineData(false, null, null, null, true, null, 43, null, null, 43)]

// global expiration; expiration bleeds into local expiration (but not the other way around)
[InlineData(true, 42, null, null, false, null, null, null, 42, 42)]
[InlineData(true, 42, 43, null, false, null, null, null, 42, 43)]
[InlineData(true, null, 43, null, false, null, null, null, null, 43)]

// both expirations specified; expiration bleeds into local expiration (but not the other way around)
[InlineData(true, 42, 43, null, true, null, null, null, 42, 43)]
[InlineData(true, 42, 43, null, true, 44, null, null, 44, 44)]
[InlineData(true, 42, 43, null, true, 44, 45, null, 44, 45)]
[InlineData(true, 42, 43, null, true, null, 45, null, 42, 45)]

[System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters",
Justification = "Most pragmatic and readable way of expressing multiple scenarios.")]
public void VerifyCacheEntryOptionsScenarios(
bool defaultsSpecified, int? defaultExpiration, int? defaultLocalCacheExpiration, HybridCacheEntryFlags? defaultFlags,
bool perItemSpecified, int? perItemExpiration, int? perItemLocalCacheExpiration, HybridCacheEntryFlags? perItemFlags,
int? expectedExpiration = null, int? expectedLocalCacheExpiration = null, HybridCacheEntryFlags expectedFlags = HybridCacheEntryFlags.None)
{
expectedFlags |= HybridCacheEntryFlags.DisableDistributedCache; // hard flag because no L2 present

var services = new ServiceCollection();
services.AddHybridCache(options =>
{
if (defaultsSpecified)
{
options.DefaultEntryOptions = new()
{
Expiration = defaultExpiration is null ? null : TimeSpan.FromMinutes(defaultExpiration.GetValueOrDefault()),
LocalCacheExpiration = defaultLocalCacheExpiration is null ? null : TimeSpan.FromMinutes(defaultLocalCacheExpiration.GetValueOrDefault()),
Flags = defaultFlags,
};
}
});

using ServiceProvider provider = services.BuildServiceProvider();
var cache = Assert.IsType<DefaultHybridCache>(provider.GetRequiredService<HybridCache>());

HybridCacheEntryOptions? itemOptions = null;
if (perItemSpecified)
{
itemOptions = new()
{
Expiration = perItemExpiration is null ? null : TimeSpan.FromMinutes(perItemExpiration.GetValueOrDefault()),
LocalCacheExpiration = perItemLocalCacheExpiration is null ? null : TimeSpan.FromMinutes(perItemLocalCacheExpiration.GetValueOrDefault()),
Flags = perItemFlags,
};
}

Assert.Equal(expectedFlags, cache.GetEffectiveFlags(itemOptions));
Assert.Equal(TimeSpan.FromMinutes(expectedExpiration ?? DefaultHybridCache.DefaultExpirationMinutes), cache.GetL2AbsoluteExpirationRelativeToNow(itemOptions));
Assert.Equal(TimeSpan.FromMinutes(expectedLocalCacheExpiration ?? DefaultHybridCache.DefaultExpirationMinutes), cache.GetL1AbsoluteExpirationRelativeToNow(itemOptions));
}

private class CustomMemoryCache : MemoryCache
{
public CustomMemoryCache(IOptions<MemoryCacheOptions> options)
Expand Down