This is an automated email from the ASF dual-hosted git repository.
ptupitsyn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git
The following commit(s) were added to refs/heads/main by this push:
new 8a5bd795d06 IGNITE-23973 .NET: Add expiration support to
IgniteDistributedCache (#7417)
8a5bd795d06 is described below
commit 8a5bd795d067e59d27342c50d82923e4508a6884
Author: Pavel Tupitsyn <[email protected]>
AuthorDate: Mon Jan 19 14:49:29 2026 +0200
IGNITE-23973 .NET: Add expiration support to IgniteDistributedCache (#7417)
* Add full expiration support to `IgniteDistributedCache`: absolute,
sliding, relative to now
* Add background cleanup loop with configurable
`ExpiredItemsCleanupInterval`
* Add `SlidingExpirationRefreshThreshold` to avoid updating the entry on
every access
---
.../IgniteDistributedCacheTests.cs | 198 +++++++++++++++--
.../IgniteDistributedCache.cs | 234 +++++++++++++++++----
.../IgniteDistributedCacheOptions.cs | 44 +++-
.../Internal/CacheEntry.cs | 6 +-
.../Internal/CacheEntryMapper.cs | 20 +-
.../Apache.Ignite.Benchmarks.csproj | 1 +
.../IgniteDistributedCacheBenchmarks.cs | 95 +++++++++
.../dotnet/Apache.Ignite.Benchmarks/Program.cs | 4 +-
.../dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs | 4 +
9 files changed, 539 insertions(+), 67 deletions(-)
diff --git
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
index 3f0d53f0a41..aac6c36e787 100644
---
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
+++
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
@@ -26,17 +26,23 @@ using Microsoft.Extensions.Caching.Distributed;
/// <summary>
/// Tests for <see cref="IgniteDistributedCache"/>.
/// </summary>
-public class IgniteDistributedCacheTests : IgniteTestsBase
+[TestFixture(null)]
+[TestFixture("myPrefix_")]
+public class IgniteDistributedCacheTests(string keyPrefix) : IgniteTestsBase
{
private IgniteClientGroup _clientGroup = null!;
// Override the base client to avoid causality issues due to a separate
client instance.
- private new IIgnite Client { get; set; }
+ private new IIgnite Client { get; set; } = null!;
[OneTimeSetUp]
public async Task InitClientGroup()
{
- _clientGroup = new IgniteClientGroup(new
IgniteClientGroupConfiguration { ClientConfiguration = GetConfig() });
+ _clientGroup = new IgniteClientGroup(new IgniteClientGroupConfiguration
+ {
+ ClientConfiguration = GetConfig(Logger)
+ });
+
Client = await _clientGroup.GetIgniteAsync();
}
@@ -176,7 +182,7 @@ public class IgniteDistributedCacheTests : IgniteTestsBase
await Client.Sql.ExecuteAsync(null, $"DROP TABLE IF EXISTS
{tableName}");
- IDistributedCache cache = GetCache(new() { TableName = tableName });
+ IDistributedCache cache = GetCache(new() { TableName = tableName,
CacheKeyPrefix = keyPrefix});
await cache.SetAsync("x", [1]);
Assert.AreEqual(new[] { 1 }, await cache.GetAsync("x"));
@@ -187,9 +193,10 @@ public class IgniteDistributedCacheTests : IgniteTestsBase
{
var cacheOptions = new IgniteDistributedCacheOptions
{
- TableName = nameof(TestCustomTableAndColumnNames),
+ TableName = keyPrefix + nameof(TestCustomTableAndColumnNames),
KeyColumnName = "_K",
- ValueColumnName = "_V"
+ ValueColumnName = "_V",
+ CacheKeyPrefix = keyPrefix
};
IDistributedCache cache = GetCache(cacheOptions);
@@ -198,14 +205,17 @@ public class IgniteDistributedCacheTests : IgniteTestsBase
CollectionAssert.AreEqual(new[] { 1 }, await cache.GetAsync("x"));
await using var resultSet = await Client.Sql.ExecuteAsync(null,
$"SELECT * FROM {cacheOptions.TableName}");
+
var rows = await resultSet.ToListAsync();
+ Assert.AreEqual(1, rows.Count, "Expected exactly one row in the table:
" + string.Join(", ", rows));
+
var row = rows.Single();
Assert.AreEqual(4, row.FieldCount);
Assert.AreEqual("_K", row.GetName(0));
Assert.AreEqual("_V", row.GetName(1));
- Assert.AreEqual("x", row[0]);
+ Assert.AreEqual(keyPrefix + "x", row[0]);
Assert.AreEqual(new[] { 1 }, (byte[]?)row[1]);
}
@@ -225,21 +235,175 @@ public class IgniteDistributedCacheTests :
IgniteTestsBase
}
[Test]
- public void TestExpirationNotSupported()
+ public async Task TestAbsoluteExpirationRelativeToNow()
+ {
+ IDistributedCache cache = GetCache();
+
+ var entryOptions = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(0.5)
+ };
+
+ await cache.SetAsync("x", [1], entryOptions);
+ Assert.IsNotNull(await cache.GetAsync("x"));
+
+ await TestUtils.WaitForConditionAsync(async () => await
cache.GetAsync("x") == null);
+ }
+
+ [Test]
+ public async Task TestAbsoluteExpiration()
+ {
+ IDistributedCache cache = GetCache();
+ await cache.SetAsync("x", [1]); // Warm up without expiration.
+
+ var entryOptions = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds(0.5)
+ };
+
+ await cache.SetAsync("x", [1], entryOptions);
+ Assert.IsNotNull(await cache.GetAsync("x"));
+
+ await Task.Delay(TimeSpan.FromSeconds(0.7));
+
+ Assert.IsNull(await cache.GetAsync("x"));
+ }
+
+ [Test]
+ public async Task TestSlidingExpiration()
+ {
+ IDistributedCache cache = GetCache();
+
+ var entryOptions = new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromSeconds(0.5)
+ };
+
+ await cache.SetAsync("x", [1], entryOptions);
+ Assert.IsNotNull(await cache.GetAsync("x"));
+
+ // Access before expiration to reset the timer.
+ await Task.Delay(TimeSpan.FromSeconds(0.3));
+ Assert.IsNotNull(await cache.GetAsync("x"));
+
+ // Wait less than the sliding window - should still be available.
+ await Task.Delay(TimeSpan.FromSeconds(0.3));
+ Assert.IsNotNull(await cache.GetAsync("x"));
+
+ // Wait for expiration without accessing.
+ await Task.Delay(TimeSpan.FromSeconds(0.7));
+ Assert.IsNull(await cache.GetAsync("x"));
+ }
+
+ [Test]
+ public async Task TestExpiredItemsCleanup()
+ {
+ var cacheOptions = new IgniteDistributedCacheOptions
+ {
+ ExpiredItemsCleanupInterval = TimeSpan.FromSeconds(1),
+ TableName = nameof(TestExpiredItemsCleanup),
+ CacheKeyPrefix = keyPrefix
+ };
+
+ IDistributedCache cache = GetCache(cacheOptions);
+
+ var entryOptions = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(0.5)
+ };
+
+ // Set multiple items with expiration.
+ await cache.SetAsync("x1", [1], entryOptions);
+ await cache.SetAsync("x2", [2], entryOptions);
+
+ Assert.IsNotNull(await cache.GetAsync("x1"));
+ Assert.IsNotNull(await cache.GetAsync("x2"));
+
+ // Wait for expiration.
+ await Task.Delay(TimeSpan.FromSeconds(0.7));
+
+ // Check cache.
+ Assert.IsNull(await cache.GetAsync("x1"));
+ Assert.IsNull(await cache.GetAsync("x2"));
+
+ // Check the underlying table.
+ await TestUtils.WaitForConditionAsync(
+ async () =>
+ {
+ var sql = $"SELECT * FROM {cacheOptions.TableName}";
+ await using var resultSet = await
Client.Sql.ExecuteAsync(null, sql);
+ return await resultSet.CountAsync() == 0;
+ },
+ timeoutMs: 3000,
+ messageFactory: () => "Expired items should be cleaned up from the
table");
+ }
+
+ [Test]
+ public async Task TestRefreshExtendsSlidingExpiration()
{
- var cache = GetCache();
+ IDistributedCache cache = GetCache();
+ await cache.RefreshAsync("x"); // Warm up (the first refresh query can
take longer).
+
+ var entryOptions = new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromSeconds(1)
+ };
- Test(new() { AbsoluteExpiration = DateTimeOffset.Now });
- Test(new() { SlidingExpiration = TimeSpan.FromMinutes(1) });
- Test(new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
+ await cache.SetAsync("x", [1], entryOptions);
- void Test(DistributedCacheEntryOptions options)
+ // Refresh multiple times to extend expiration.
+ for (int i = 0; i < 10; i++)
{
- var ex = Assert.Throws<ArgumentException>(() => cache.Set("x",
[1], options));
- Assert.AreEqual("Expiration is not supported. (Parameter
'options')", ex.Message);
+ await Task.Delay(TimeSpan.FromSeconds(0.1));
+ await cache.RefreshAsync("x");
}
+
+ // Check that the item is still available.
+ Assert.IsNotNull(await cache.GetAsync("x"));
+
+ // Wait for final expiration.
+ await Task.Delay(TimeSpan.FromSeconds(1.2));
+ Assert.IsNull(await cache.GetAsync("x"));
}
- private IDistributedCache GetCache(IgniteDistributedCacheOptions? options
= null) =>
- new IgniteDistributedCache(options ?? new
IgniteDistributedCacheOptions(), _clientGroup);
+ [Test]
+ public void TestRefreshOnNonExistentKey()
+ {
+ Assert.DoesNotThrowAsync(async () => await
GetCache().RefreshAsync("non-existent-key"));
+ }
+
+ [Test]
+ public async Task TestRefreshOnExpiredKey()
+ {
+ IDistributedCache cache = GetCache();
+
+ var entryOptions = new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromSeconds(0.1)
+ };
+
+ await cache.SetAsync("x", [1], entryOptions);
+
+ // Wait for expiration.
+ await Task.Delay(TimeSpan.FromSeconds(0.3));
+ Assert.IsNull(await cache.GetAsync("x"));
+
+ // Refresh on an expired key should not resurrect it.
+ await cache.RefreshAsync("x");
+ Assert.IsNull(await cache.GetAsync("x"));
+ }
+
+ private IDistributedCache GetCache(IgniteDistributedCacheOptions? options
= null)
+ {
+ var ops = options ?? new IgniteDistributedCacheOptions
+ {
+ CacheKeyPrefix = keyPrefix
+ };
+
+ var cache = new IgniteDistributedCache(ops, _clientGroup);
+
+ AddDisposable(cache);
+
+ return cache;
+ }
}
diff --git
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs
index 6d4104501f3..5540ee6ff53 100644
---
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs
+++
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs
@@ -19,6 +19,7 @@ namespace Apache.Extensions.Caching.Ignite;
using System.Diagnostics.CodeAnalysis;
using Apache.Ignite;
+using Apache.Ignite.Sql;
using Apache.Ignite.Table;
using Internal;
using Microsoft.Extensions.Caching.Distributed;
@@ -30,12 +31,6 @@ using Microsoft.Extensions.Options;
/// </summary>
public sealed class IgniteDistributedCache : IDistributedCache, IDisposable
{
- /** Absolute expiration timestamp, milliseconds since Unix epoch. */
- private const string ExpirationColumnName = "EXPIRATION";
-
- /** Sliding expiration, milliseconds. */
- private const string SlidingExpirationColumnName = "SLIDING_EXPIRATION";
-
[SuppressMessage("Usage", "CA2213:Disposable fields should be disposed",
Justification = "Not owned, injected.")]
private readonly IgniteClientGroup _igniteClientGroup;
@@ -45,7 +40,13 @@ public sealed class IgniteDistributedCache :
IDistributedCache, IDisposable
private readonly SemaphoreSlim _initLock = new(1);
- private volatile IKeyValueView<string, CacheEntry>? _view;
+ private readonly CancellationTokenSource _cleanupCts = new();
+
+ private readonly SqlStatement _refreshSql;
+
+ private readonly SqlStatement _cleanupSql;
+
+ private volatile IgniteHolder? _tableHolder;
/// <summary>
/// Initializes a new instance of the <see cref="IgniteDistributedCache"/>
class.
@@ -74,9 +75,25 @@ public sealed class IgniteDistributedCache :
IDistributedCache, IDisposable
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(igniteClientGroup);
+ Validate(options);
+
_options = options;
_cacheEntryMapper = new CacheEntryMapper(options);
_igniteClientGroup = igniteClientGroup;
+
+ _refreshSql = $"UPDATE {_options.TableName} " +
+ $"SET {_options.ExpirationColumnName} =
{_options.SlidingExpirationColumnName} + ? " + // expiration = sliding + now
+ $"WHERE {_options.KeyColumnName} = ? " +
+ $"AND {_options.SlidingExpirationColumnName} IS NOT NULL
" + // Has sliding expiration
+ $"AND {_options.ExpirationColumnName} > ?"; // Not
expired
+
+ var expireAtCol = _options.ExpirationColumnName;
+ _cleanupSql = $"DELETE FROM {_options.TableName} WHERE {expireAtCol}
IS NOT NULL AND {expireAtCol} <= ?";
+
+ if (_options.ExpiredItemsCleanupInterval != Timeout.InfiniteTimeSpan)
+ {
+ _ = CleanupLoopAsync();
+ }
}
/// <inheritdoc/>
@@ -88,10 +105,32 @@ public sealed class IgniteDistributedCache :
IDistributedCache, IDisposable
{
ArgumentNullException.ThrowIfNull(key);
- var view = await GetViewAsync().ConfigureAwait(false);
+ var holder = await EnsureInitAsync().ConfigureAwait(false);
+
+ var (val, hasVal) = await holder.View.GetAsync(null,
key).ConfigureAwait(false);
+ if (!hasVal)
+ {
+ return null;
+ }
+
+ var now = UtcNowMillis();
+ if (val.ExpiresAt is { } exp)
+ {
+ var diff = exp - now;
+
+ if (diff <= 0)
+ {
+ return null;
+ }
+
+ if (val.SlidingExpiration is { } sliding &&
+ diff < sliding * _options.SlidingExpirationRefreshThreshold)
+ {
+ await RefreshAsync(key, token).ConfigureAwait(false);
+ }
+ }
- (CacheEntry val, bool hasVal) = await view.GetAsync(null,
key).ConfigureAwait(false);
- return hasVal ? val.Value : null;
+ return val.Value;
}
/// <inheritdoc/>
@@ -105,34 +144,34 @@ public sealed class IgniteDistributedCache :
IDistributedCache, IDisposable
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(options);
- if (options.AbsoluteExpiration != null || options.SlidingExpiration !=
null || options.AbsoluteExpirationRelativeToNow != null)
- {
- // TODO: IGNITE-23973 Add expiration support
- throw new ArgumentException("Expiration is not supported.",
nameof(options));
- }
-
- var entry = new CacheEntry(value);
+ // Important to init first and calculate expiration after.
+ // Initialization can take time.
+ var holder = await EnsureInitAsync().ConfigureAwait(false);
- IKeyValueView<string, CacheEntry> view = await
GetViewAsync().ConfigureAwait(false);
+ (long? expiresAt, long? sliding) = GetExpiration(options);
+ var entry = new CacheEntry(value, expiresAt, sliding);
- await view.PutAsync(transaction: null, key,
entry).ConfigureAwait(false);
+ await holder.View.PutAsync(transaction: null, key,
entry).ConfigureAwait(false);
}
/// <inheritdoc/>
- public void Refresh(string key)
- {
- ArgumentNullException.ThrowIfNull(key);
-
- // TODO: IGNITE-23973 Add expiration support
- }
+ public void Refresh(string key) =>
+ RefreshAsync(key, CancellationToken.None).GetAwaiter().GetResult();
/// <inheritdoc/>
- public Task RefreshAsync(string key, CancellationToken token)
+ public async Task RefreshAsync(string key, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(key);
- // TODO: IGNITE-23973 Add expiration support
- return Task.CompletedTask;
+ var holder = await EnsureInitAsync().ConfigureAwait(false);
+
+ string actualKey = _options.CacheKeyPrefix + key;
+ long now = UtcNowMillis();
+
+ await holder.Ignite.Sql.ExecuteScriptAsync(
+ _refreshSql,
+ token,
+ args: [now, actualKey, now]).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -144,32 +183,106 @@ public sealed class IgniteDistributedCache :
IDistributedCache, IDisposable
{
ArgumentNullException.ThrowIfNull(key);
- IKeyValueView<string, CacheEntry> view = await
GetViewAsync().ConfigureAwait(false);
- await view.RemoveAsync(null, key).ConfigureAwait(false);
+ var holder = await EnsureInitAsync().ConfigureAwait(false);
+ await holder.View.RemoveAsync(null, key).ConfigureAwait(false);
}
/// <inheritdoc/>
public void Dispose()
{
+ if (_cleanupCts.IsCancellationRequested)
+ {
+ return;
+ }
+
+ _cleanupCts.Cancel();
_initLock.Dispose();
+ _cleanupCts.Dispose();
+ }
+
+ private static void Validate(IgniteDistributedCacheOptions options)
+ {
+ if (options.ExpiredItemsCleanupInterval != Timeout.InfiniteTimeSpan &&
options.ExpiredItemsCleanupInterval <= TimeSpan.Zero)
+ {
+ throw new ArgumentException("ExpiredItemsCleanupInterval must be
positive or Timeout.InfiniteTimeSpan.", nameof(options));
+ }
+
+ if (options.SlidingExpirationRefreshThreshold is < 0.0 or > 1.0)
+ {
+ throw new ArgumentException("SlidingExpirationRefreshThreshold
must be between 0.0 and 1.0.", nameof(options));
+ }
+
+ if (string.IsNullOrWhiteSpace(options.TableName))
+ {
+ throw new ArgumentException("TableName cannot be null or
whitespace.", nameof(options));
+ }
+
+ if (string.IsNullOrWhiteSpace(options.KeyColumnName))
+ {
+ throw new ArgumentException("KeyColumnName cannot be null or
whitespace.", nameof(options));
+ }
+
+ if (string.IsNullOrWhiteSpace(options.ValueColumnName))
+ {
+ throw new ArgumentException("ValueColumnName cannot be null or
whitespace.", nameof(options));
+ }
+
+ if (string.IsNullOrWhiteSpace(options.ExpirationColumnName))
+ {
+ throw new ArgumentException("ExpirationColumnName cannot be null
or whitespace.", nameof(options));
+ }
+
+ if (string.IsNullOrWhiteSpace(options.SlidingExpirationColumnName))
+ {
+ throw new ArgumentException("SlidingExpirationColumnName cannot be
null or whitespace.", nameof(options));
+ }
+ }
+
+ private static long UtcNowMillis() =>
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ private static (long? Absolute, long? Sliding)
GetExpiration(DistributedCacheEntryOptions options)
+ {
+ long absExpAt = options.AbsoluteExpiration is { } absExp
+ ? absExp.ToUnixTimeMilliseconds()
+ : long.MaxValue;
+
+ long absExpAtRel = options.AbsoluteExpirationRelativeToNow is { }
absExpRel
+ ? UtcNowMillis() + (long)absExpRel.TotalMilliseconds
+ : long.MaxValue;
+
+ long? sliding = options.SlidingExpiration is { } slidingExp
+ ? (long)slidingExp.TotalMilliseconds
+ : null;
+
+ long absExpAtSliding = sliding is { } sliding0
+ ? UtcNowMillis() + sliding0
+ : long.MaxValue;
+
+ long? expiresAt = Math.Min(Math.Min(absExpAt, absExpAtRel),
absExpAtSliding) switch
+ {
+ var min when min != long.MaxValue => min,
+ _ => null
+ };
+
+ return (expiresAt, sliding);
}
- private async Task<IKeyValueView<string, CacheEntry>> GetViewAsync()
+ private async Task<IgniteHolder> EnsureInitAsync()
{
- var view = _view;
- if (view != null)
+ var holder = _tableHolder;
+ if (holder != null)
{
- return view;
+ return holder;
}
await _initLock.WaitAsync().ConfigureAwait(false);
try
{
- view = _view;
- if (view != null)
+ holder = _tableHolder;
+ if (holder != null)
{
- return view;
+ return holder;
}
IIgnite ignite = await
_igniteClientGroup.GetIgniteAsync().ConfigureAwait(false);
@@ -180,23 +293,58 @@ public sealed class IgniteDistributedCache :
IDistributedCache, IDisposable
var sql = $"CREATE TABLE IF NOT EXISTS {tableName} (" +
$"{_options.KeyColumnName} VARCHAR PRIMARY KEY, " +
$"{_options.ValueColumnName} VARBINARY, " +
- $"{ExpirationColumnName} BIGINT, " +
- $"{SlidingExpirationColumnName} BIGINT" +
+ $"{_options.ExpirationColumnName} BIGINT, " +
+ $"{_options.SlidingExpirationColumnName} BIGINT" +
")";
- await ignite.Sql.ExecuteAsync(transaction: null,
sql).ConfigureAwait(false);
+ await ignite.Sql.ExecuteScriptAsync(sql).ConfigureAwait(false);
var table = await
ignite.Tables.GetTableAsync(tableName).ConfigureAwait(false)
?? throw new InvalidOperationException("Table not
found: " + tableName);
- view = table.GetKeyValueView(_cacheEntryMapper);
- _view = view;
+ var view = table.GetKeyValueView(_cacheEntryMapper);
+ holder = new IgniteHolder(view, ignite);
+ _tableHolder = holder;
- return view;
+ return holder;
}
finally
{
_initLock.Release();
}
}
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
Justification = "Background loop.")]
+ private async Task CleanupLoopAsync()
+ {
+ while (!_cleanupCts.Token.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_options.ExpiredItemsCleanupInterval,
_cleanupCts.Token).ConfigureAwait(false);
+ await
CleanupExpiredEntriesAsync(_cleanupCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception)
+ {
+ // Swallow exceptions - might be intermittent connection
errors.
+ // Client will log the error and retry on the next iteration.
+ }
+ }
+ }
+
+ private async Task CleanupExpiredEntriesAsync(CancellationToken token)
+ {
+ var holder = await EnsureInitAsync().ConfigureAwait(false);
+
+ await holder.Ignite.Sql.ExecuteScriptAsync(
+ _cleanupSql,
+ token,
+ args: UtcNowMillis()).ConfigureAwait(false);
+ }
+
+ private sealed record IgniteHolder(IKeyValueView<string, CacheEntry> View,
IIgnite Ignite);
}
diff --git
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCacheOptions.cs
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCacheOptions.cs
index dfd59deb536..3efa548f2a1 100644
---
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCacheOptions.cs
+++
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCacheOptions.cs
@@ -35,17 +35,27 @@ public sealed record IgniteDistributedCacheOptions :
IOptions<IgniteDistributedC
public string TableName { get; set; } = "IGNITE_DOTNET_DISTRIBUTED_CACHE";
/// <summary>
- /// Gets or sets the name of the key column. Column type should be VARCHAR.
+ /// Gets or sets the name of the key column. The column type should be
VARCHAR.
/// </summary>
public string KeyColumnName { get; set; } = "KEY";
/// <summary>
- /// Gets or sets the name of the value column. Column type should be
VARBINARY.
+ /// Gets or sets the name of the value column. The column type should be
VARBINARY.
/// </summary>
public string ValueColumnName { get; set; } = "VAL";
/// <summary>
- /// Gets or sets optional cache key prefix. Allows to use the same table
for multiple caches.
+ /// Gets or sets the name of the expiration column. The column type should
be BIGINT.
+ /// </summary>
+ public string ExpirationColumnName { get; set; } = "EXPIRATION";
+
+ /// <summary>
+ /// Gets or sets the name of the sliding expiration column. The column
type should be BIGINT.
+ /// </summary>
+ public string SlidingExpirationColumnName { get; set; } =
"SLIDING_EXPIRATION";
+
+ /// <summary>
+ /// Gets or sets optional cache key prefix. Allows using the same table
for multiple caches.
/// </summary>
public string? CacheKeyPrefix { get; set; }
@@ -55,6 +65,34 @@ public sealed record IgniteDistributedCacheOptions :
IOptions<IgniteDistributedC
/// </summary>
public object? IgniteClientGroupServiceKey { get; set; }
+ /// <summary>
+ /// Gets or sets the interval for expired items cleanup.
+ /// <para />
+ /// Set to <see cref="Timeout.InfiniteTimeSpan"/> to disable automatic
cleanup.
+ /// <para />
+ /// Default is 5 minutes.
+ /// <para />
+ /// NOTE: Every cache instance performs its own cleanup task.
+ /// In a distributed environment, where N instances of the application
exist, this means N times more cleanup operations,
+ /// roughly equivalent to N times shorter cleanup interval.
+ /// </summary>
+ public TimeSpan ExpiredItemsCleanupInterval { get; set; } =
TimeSpan.FromMinutes(5);
+
+ /// <summary>
+ /// Gets or sets the threshold for sliding expiration refresh as a
percentage of the sliding expiration period.
+ /// <para />
+ /// When getting a cache entry with sliding expiration, the entry will be
refreshed only if the remaining time
+ /// until expiration is less than this threshold multiplied by the sliding
expiration period.
+ /// <para />
+ /// For example, with a sliding expiration of 10 minutes and a threshold
of 0.2 (20%), the entry will only
+ /// be refreshed if it has less than 2 minutes remaining before expiration.
+ /// <para />
+ /// The default is 0.5 (50%). Set to a lower value to reduce the number of
refresh operations.
+ /// <para />
+ /// Valid range: 0.0 to 1.0.
+ /// </summary>
+ public double SlidingExpirationRefreshThreshold { get; set; } = 0.5;
+
/// <inheritdoc/>
IgniteDistributedCacheOptions
IOptions<IgniteDistributedCacheOptions>.Value => this;
}
diff --git
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntry.cs
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntry.cs
index c50e22cc489..d7b6ebcede5 100644
---
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntry.cs
+++
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntry.cs
@@ -23,6 +23,10 @@ using System.Diagnostics.CodeAnalysis;
/// Cache entry DTO.
/// </summary>
/// <param name="Value">Value bytes.</param>
+/// <param name="ExpiresAt">Expiration (Unix time millis).</param>
+/// <param name="SlidingExpiration">Sliding expiration (millis).</param>
[SuppressMessage("Performance", "CA1819:Properties should not return arrays",
Justification = "Internal DTO")]
internal record struct CacheEntry(
- byte[] Value);
+ byte[] Value,
+ long? ExpiresAt,
+ long? SlidingExpiration);
diff --git
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntryMapper.cs
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntryMapper.cs
index ded960aac73..bb46edad6a6 100644
---
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntryMapper.cs
+++
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/Internal/CacheEntryMapper.cs
@@ -45,6 +45,14 @@ internal sealed class CacheEntryMapper :
IMapper<KeyValuePair<string, CacheEntry
{
rowWriter.WriteBytes(obj.Value.Value);
}
+ else if (column.Name == _options.ExpirationColumnName)
+ {
+ rowWriter.WriteLong(obj.Value.ExpiresAt);
+ }
+ else if (column.Name == _options.SlidingExpirationColumnName)
+ {
+ rowWriter.WriteLong(obj.Value.SlidingExpiration);
+ }
else
{
rowWriter.Skip();
@@ -57,6 +65,8 @@ internal sealed class CacheEntryMapper :
IMapper<KeyValuePair<string, CacheEntry
{
string? key = null;
byte[]? value = null;
+ long? expiresAt = null;
+ long? slidingExpiration = null;
foreach (var column in schema.Columns)
{
@@ -68,6 +78,14 @@ internal sealed class CacheEntryMapper :
IMapper<KeyValuePair<string, CacheEntry
{
value = rowReader.ReadBytes();
}
+ else if (column.Name == _options.ExpirationColumnName)
+ {
+ expiresAt = rowReader.ReadLong();
+ }
+ else if (column.Name == _options.SlidingExpirationColumnName)
+ {
+ slidingExpiration = rowReader.ReadLong();
+ }
else
{
rowReader.Skip();
@@ -89,6 +107,6 @@ internal sealed class CacheEntryMapper :
IMapper<KeyValuePair<string, CacheEntry
key = key[prefix.Length..];
}
- return KeyValuePair.Create(key, new CacheEntry(value));
+ return KeyValuePair.Create(key, new CacheEntry(value, expiresAt,
slidingExpiration));
}
}
diff --git
a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Apache.Ignite.Benchmarks.csproj
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Apache.Ignite.Benchmarks.csproj
index 4919948ec16..2d37c51fd15 100644
---
a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Apache.Ignite.Benchmarks.csproj
+++
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Apache.Ignite.Benchmarks.csproj
@@ -33,6 +33,7 @@
<ItemGroup>
<ProjectReference Include="..\Apache.Ignite\Apache.Ignite.csproj" />
+ <ProjectReference
Include="..\Apache.Extensions.Caching.Ignite\Apache.Extensions.Caching.Ignite.csproj"
/>
<ProjectReference
Include="..\Apache.Ignite.Tests\Apache.Ignite.Tests.csproj" />
</ItemGroup>
diff --git
a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/DistributedCache/IgniteDistributedCacheBenchmarks.cs
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/DistributedCache/IgniteDistributedCacheBenchmarks.cs
new file mode 100644
index 00000000000..edfca88dc17
--- /dev/null
+++
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/DistributedCache/IgniteDistributedCacheBenchmarks.cs
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+namespace Apache.Ignite.Benchmarks.DistributedCache;
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using BenchmarkDotNet.Attributes;
+using Extensions.Caching.Ignite;
+using DistributedCacheEntryOptions =
Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions;
+
+/// <summary>
+/// Benchmarks for <see cref="IgniteDistributedCache"/>.
+/// <para />
+/// Results on i9-12900H, .NET SDK 8.0.15, Ubuntu 22.04:
+/// | Method | Mean | Error | StdDev | Allocated |
+/// |-------- |------------:|----------:|----------:|----------:|
+/// | Get | 48.35 us | 0.960 us | 1.179 us | 2.95 KB |
+/// | Set | 108.16 us | 2.133 us | 3.563 us | 2.73 KB |
+/// | Refresh | 1,388.33 us | 27.221 us | 48.385 us | 2.38 KB |.
+/// </summary>
+[MemoryDiagnoser]
+public class IgniteDistributedCacheBenchmarks : ServerBenchmarkBase
+{
+ private const string Key = "key1";
+
+ private const string KeySliding = "keySliding";
+
+ private static readonly DistributedCacheEntryOptions DefaultOpts = new();
+
+ private static readonly byte[] Val = [1, 2, 3];
+
+ private IgniteClientGroup _clientGroup = null!;
+
+ private IgniteDistributedCache _cache = null!;
+
+ public override async Task GlobalSetup()
+ {
+ await base.GlobalSetup();
+
+ var groupCfg = new IgniteClientGroupConfiguration {
ClientConfiguration = Client.Configuration };
+ _clientGroup = new IgniteClientGroup(groupCfg);
+
+ var cacheOptions = new IgniteDistributedCacheOptions
+ {
+ ExpiredItemsCleanupInterval = Timeout.InfiniteTimeSpan
+ };
+
+ _cache = new IgniteDistributedCache(cacheOptions, _clientGroup);
+
+ await _cache.SetAsync(Key, Val, DefaultOpts, CancellationToken.None);
+
+ var slidingOpts = new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromHours(1)
+ };
+
+ await _cache.SetAsync(KeySliding, Val, slidingOpts,
CancellationToken.None);
+ }
+
+ public override async Task GlobalCleanup()
+ {
+ _cache.Dispose();
+ _clientGroup.Dispose();
+
+ await base.GlobalCleanup();
+ }
+
+ [Benchmark]
+ public async Task Get() =>
+ await _cache.GetAsync(Key, CancellationToken.None);
+
+ [Benchmark]
+ public async Task Set() =>
+ await _cache.SetAsync(Key, Val, DefaultOpts, CancellationToken.None);
+
+ [Benchmark]
+ public async Task Refresh() =>
+ await _cache.RefreshAsync(KeySliding, CancellationToken.None);
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
index 19ad3abdf74..303a4e3da14 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
@@ -18,10 +18,10 @@
namespace Apache.Ignite.Benchmarks;
using BenchmarkDotNet.Running;
-using Compute;
+using DistributedCache;
internal static class Program
{
// IMPORTANT: Disable Netty leak detector when using a real Ignite server
for benchmarks.
- private static void Main() => BenchmarkRunner.Run<PlatformJobBenchmarks>();
+ private static void Main() =>
BenchmarkRunner.Run<IgniteDistributedCacheBenchmarks>();
}
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
b/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
index 2512b10e9e3..92f9e741179 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
@@ -82,6 +82,8 @@ namespace Apache.Ignite.Tests
protected bool UseMapper { get; }
+ protected ConsoleLogger Logger => _logger;
+
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
@@ -216,6 +218,8 @@ namespace Apache.Ignite.Tests
return proxies;
}
+ protected void AddDisposable(IDisposable disposable) =>
_disposables.Add(disposable);
+
private void CheckPooledBufferLeak()
{
// Use WaitForCondition to check rented/returned buffers equality: