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:


Reply via email to