Copilot commented on code in PR #7417:
URL: https://github.com/apache/ignite-3/pull/7417#discussion_r2694097567


##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -199,4 +258,35 @@ private async Task<IKeyValueView<string, CacheEntry>> 
GetViewAsync()
             _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.

Review Comment:
   The comment states "Client will log the error and retry on the next 
iteration" but there is no explicit logging in the catch block. While the 
underlying Ignite client may log errors internally, the comment is misleading 
about where the logging occurs. Consider either adding explicit logging here or 
updating the comment to clarify that logging happens at a lower level.
   ```suggestion
                   // Swallow exceptions - might be intermittent connection 
errors; loop will retry on the next iteration.
   ```



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -77,6 +73,16 @@ public IgniteDistributedCache(
         _options = options;
         _cacheEntryMapper = new CacheEntryMapper(options);
         _igniteClientGroup = igniteClientGroup;
+
+        if (_options.ExpiredItemsCleanupInterval != Timeout.InfiniteTimeSpan)
+        {
+            if (_options.ExpiredItemsCleanupInterval <= TimeSpan.Zero)
+            {
+                throw new ArgumentException("ExpiredItemsCleanupInterval must 
be positive or Timeout.InfiniteTimeSpan.", nameof(options));
+            }
+
+            _ = CleanupLoopAsync();

Review Comment:
   The background cleanup task started in CleanupLoopAsync is not properly 
awaited, which may lead to unobserved task exceptions. The task is discarded 
with the discard operator (_), but if an exception occurs after the Dispose 
method is called, it won't be properly handled. Consider storing the Task 
reference and awaiting it during disposal, or use a more robust pattern for 
background task management.
   ```suggestion
               _ = CleanupLoopAsync().ContinueWith(
                   t =>
                   {
                       // Observe exception to avoid unobserved task exceptions 
for the background cleanup loop.
                       var _ = t.Exception;
                   },
                   
System.Threading.Tasks.TaskContinuationOptions.OnlyOnFaulted);
   ```



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs:
##########
@@ -225,21 +231,112 @@ public async Task TestKeyPrefix()
     }
 
     [Test]
-    public void TestExpirationNotSupported()
+    public async Task TestAbsoluteExpiration()
+    {
+        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 Task.Delay(TimeSpan.FromSeconds(0.7));
+
+        Assert.IsNull(await cache.GetAsync("x"));
+    }
+
+    [Test]
+    public async Task TestAbsoluteExpirationRelativeToNow()
+    {
+        IDistributedCache cache = GetCache();
+
+        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()
     {
-        var cache = GetCache();
+        IDistributedCache cache = GetCache();
 
-        Test(new() { AbsoluteExpiration = DateTimeOffset.Now });
-        Test(new() { SlidingExpiration = TimeSpan.FromMinutes(1) });
-        Test(new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) 
});
+        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"));
+    }
 
-        void Test(DistributedCacheEntryOptions options)
+    [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
         {
-            var ex = Assert.Throws<ArgumentException>(() => cache.Set("x", 
[1], options));
-            Assert.AreEqual("Expiration is not supported. (Parameter 
'options')", ex.Message);
-        }
+            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");

Review Comment:
   The cache instance created in this test is not disposed. Since this test 
enables the cleanup task (ExpiredItemsCleanupInterval = 
TimeSpan.FromSeconds(1)), the background cleanup loop will continue running 
after the test completes. Consider disposing the cache at the end of the test 
to properly clean up resources.
   ```suggestion
               messageFactory: () => "Expired items should be cleaned up from 
the table");
   
           if (cache is System.IAsyncDisposable asyncDisposable)
           {
               await asyncDisposable.DisposeAsync();
           }
           else if (cache is System.IDisposable disposable)
           {
               disposable.Dispose();
           }
   ```



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -199,4 +258,35 @@ private async Task<IKeyValueView<string, CacheEntry>> 
GetViewAsync()
             _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 expireAtCol = _options.ExpirationColumnName;
+        var sql = $"DELETE FROM {_options.TableName} WHERE {expireAtCol} IS 
NOT NULL AND {expireAtCol} <= ?";

Review Comment:
   The SQL statement concatenates table and column names directly from 
configuration options without any validation or sanitization. While the code 
comment at line 238 acknowledges this assumption, this could lead to SQL 
injection vulnerabilities if user-supplied values are used for TableName or 
ExpirationColumnName. Consider adding validation to ensure these values contain 
only alphanumeric characters and underscores, or use a different approach to 
construct the SQL safely.



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -77,6 +73,16 @@ public IgniteDistributedCache(
         _options = options;
         _cacheEntryMapper = new CacheEntryMapper(options);
         _igniteClientGroup = igniteClientGroup;
+
+        if (_options.ExpiredItemsCleanupInterval != Timeout.InfiniteTimeSpan)
+        {
+            if (_options.ExpiredItemsCleanupInterval <= TimeSpan.Zero)
+            {
+                throw new ArgumentException("ExpiredItemsCleanupInterval must 
be positive or Timeout.InfiniteTimeSpan.", nameof(options));
+            }

Review Comment:
   There is no test coverage for the validation logic that throws 
ArgumentException when ExpiredItemsCleanupInterval is less than or equal to 
TimeSpan.Zero (excluding Timeout.InfiniteTimeSpan). Consider adding a test to 
verify that invalid values are properly rejected.



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs:
##########
@@ -225,21 +231,112 @@ public async Task TestKeyPrefix()
     }
 
     [Test]
-    public void TestExpirationNotSupported()
+    public async Task TestAbsoluteExpiration()
+    {
+        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 Task.Delay(TimeSpan.FromSeconds(0.7));
+
+        Assert.IsNull(await cache.GetAsync("x"));
+    }
+
+    [Test]
+    public async Task TestAbsoluteExpirationRelativeToNow()
+    {
+        IDistributedCache cache = GetCache();
+
+        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"));
+    }

Review Comment:
   The test name "TestAbsoluteExpirationRelativeToNow" is misleading as it 
actually tests the AbsoluteExpiration property (not 
AbsoluteExpirationRelativeToNow). Consider renaming this test to 
"TestAbsoluteExpiration" for clarity.



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs:
##########
@@ -225,21 +231,112 @@ public async Task TestKeyPrefix()
     }
 
     [Test]
-    public void TestExpirationNotSupported()
+    public async Task TestAbsoluteExpiration()
+    {
+        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 Task.Delay(TimeSpan.FromSeconds(0.7));
+
+        Assert.IsNull(await cache.GetAsync("x"));
+    }

Review Comment:
   The test name "TestAbsoluteExpiration" is misleading as it actually tests 
AbsoluteExpirationRelativeToNow property. Consider renaming this test to 
"TestAbsoluteExpirationRelativeToNow" for clarity.



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -151,7 +174,43 @@ public async Task RemoveAsync(string key, 
CancellationToken token)
     /// <inheritdoc/>
     public void Dispose()
     {
+        if (_cleanupCts.IsCancellationRequested)
+        {
+            return;
+        }
+
+        _cleanupCts.Cancel();
         _initLock.Dispose();
+        _cleanupCts.Dispose();
+    }

Review Comment:
   The Dispose method checks if cancellation is already requested and returns 
early, which means subsequent calls to Dispose won't properly dispose of 
_initLock and _cleanupCts. While Dispose should be idempotent, the current 
implementation may skip necessary cleanup on subsequent calls. Consider using a 
dedicated disposal flag or restructuring the logic to ensure all disposable 
resources are properly cleaned up on every call.



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs:
##########
@@ -176,7 +178,7 @@ public async Task TestNonExistingTable()
 
         await Client.Sql.ExecuteAsync(null, $"DROP TABLE IF EXISTS 
{tableName}");
 
-        IDistributedCache cache = GetCache(new() { TableName = tableName });
+        IDistributedCache cache = GetCache(new() { TableName = tableName, 
CacheKeyPrefix = keyPrefix});

Review Comment:
   Missing space before the closing brace. Should be "keyPrefix }" instead of 
"keyPrefix}".
   ```suggestion
           IDistributedCache cache = GetCache(new() { TableName = tableName, 
CacheKeyPrefix = keyPrefix });
   ```



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -105,34 +132,30 @@ public async Task SetAsync(string key, byte[] value, 
DistributedCacheEntryOption
         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);
+        (long? expiresAt, long? sliding) = GetExpiration(options);
+        var entry = new CacheEntry(value, expiresAt, sliding);
 
         IKeyValueView<string, CacheEntry> view = await 
GetViewAsync().ConfigureAwait(false);
-
         await 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;
+        IIgnite ignite = await 
_igniteClientGroup.GetIgniteAsync().ConfigureAwait(false);
+
+        var sql = $"UPDATE {_options.TableName} " +
+                  $"SET {_options.ExpirationColumnName} = 
{_options.SlidingExpirationColumnName} + ? " +
+                  $"WHERE {_options.KeyColumnName} = ? AND 
{_options.SlidingExpirationColumnName} IS NOT NULL";
+
+        var actualKey = _options.CacheKeyPrefix + key;
+        await ignite.Sql.ExecuteAsync(null, sql, token, UtcNowMillis(), 
actualKey).ConfigureAwait(false);
     }

Review Comment:
   There is no test coverage for the Refresh and RefreshAsync methods. These 
methods are critical for sliding expiration behavior, yet there's no explicit 
test that calls them directly to verify they work correctly.



##########
modules/platforms/dotnet/Apache.Extensions.Caching.Ignite/IgniteDistributedCache.cs:
##########
@@ -105,34 +132,30 @@ public async Task SetAsync(string key, byte[] value, 
DistributedCacheEntryOption
         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);
+        (long? expiresAt, long? sliding) = GetExpiration(options);
+        var entry = new CacheEntry(value, expiresAt, sliding);
 
         IKeyValueView<string, CacheEntry> view = await 
GetViewAsync().ConfigureAwait(false);
-
         await 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;
+        IIgnite ignite = await 
_igniteClientGroup.GetIgniteAsync().ConfigureAwait(false);
+
+        var sql = $"UPDATE {_options.TableName} " +
+                  $"SET {_options.ExpirationColumnName} = 
{_options.SlidingExpirationColumnName} + ? " +
+                  $"WHERE {_options.KeyColumnName} = ? AND 
{_options.SlidingExpirationColumnName} IS NOT NULL";

Review Comment:
   The SQL statement concatenates table and column names directly from 
configuration options without any validation or sanitization. While the code 
comment at line 238 acknowledges this assumption, this could lead to SQL 
injection vulnerabilities if user-supplied values are used for TableName, 
KeyColumnName, ExpirationColumnName, or SlidingExpirationColumnName. Consider 
adding validation to ensure these values contain only alphanumeric characters 
and underscores, or use a different approach to construct the SQL safely.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to