From edf7ff12596f80ff3c66ac7941e6cb9e7a9de066 Mon Sep 17 00:00:00 2001 From: Matias Quaranta Date: Fri, 30 Aug 2024 10:05:36 -0700 Subject: [PATCH] Fix race condition with less than 1 seconds left (#84) * Special case * return * test * readme --- changelog.md | 6 +++++ src/CosmosCache.cs | 14 +++++++++++ src/CosmosDistributedCache.csproj | 2 +- tests/unit/CosmosCacheTests.cs | 40 +++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 9816bf0..0eedc5a 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +## 1.6.2 - 2024-08-30 + +### Fixed + +- [#84](https://github.com/Azure/Microsoft.Extensions.Caching.Cosmos/pull/84) Fix race condition with less than 1 seconds left on sliding expiration + ## 1.6.1 - 2024-03-27 ### Fixed diff --git a/src/CosmosCache.cs b/src/CosmosCache.cs index 3a23565..1e9acc4 100644 --- a/src/CosmosCache.cs +++ b/src/CosmosCache.cs @@ -129,6 +129,13 @@ public byte[] Get(string key) else { double pendingSeconds = (absoluteExpiration - DateTimeOffset.UtcNow).TotalSeconds; + if (pendingSeconds == 0) + { + // Cosmos DB TTL works on seconds granularity and this item has less than a second to live. + // Return the content because it does exist, but it will be cleaned up by the TTL shortly after. + return cosmosCacheSessionResponse.Resource.Content; + } + if (pendingSeconds < ttl) { cosmosCacheSessionResponse.Resource.TimeToLive = (long)pendingSeconds; @@ -224,6 +231,13 @@ public void Refresh(string key) else { double pendingSeconds = (absoluteExpiration - DateTimeOffset.UtcNow).TotalSeconds; + if (pendingSeconds == 0) + { + // Cosmos DB TTL works on seconds granularity and this item has less than a second to live. + // Treat it as a cache-miss. + return; + } + if (pendingSeconds < ttl) { cosmosCacheSessionResponse.Resource.TimeToLive = (long)pendingSeconds; diff --git a/src/CosmosDistributedCache.csproj b/src/CosmosDistributedCache.csproj index d1295cb..78beda4 100644 --- a/src/CosmosDistributedCache.csproj +++ b/src/CosmosDistributedCache.csproj @@ -6,7 +6,7 @@ © Microsoft Corporation. All rights reserved. $([System.DateTime]::Now.ToString(yyyyMMdd)) en-US - 1.6.1 + 1.6.2 preview $(ClientVersion) $(ClientVersion)-$(VersionSuffix) diff --git a/tests/unit/CosmosCacheTests.cs b/tests/unit/CosmosCacheTests.cs index 701bf93..4a7e096 100644 --- a/tests/unit/CosmosCacheTests.cs +++ b/tests/unit/CosmosCacheTests.cs @@ -715,6 +715,46 @@ public async Task SlidingExpirationWithAbsoluteExpirationOnReplaceNotFound() mockedContainer.Verify(c => c.ReplaceItemAsync(It.Is(item => item.TimeToLive == ttlSliding), It.Is(id => id == "key"), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task SlidingExpirationWithAbsoluteExpirationOnAlmostExpiredRead() + { + const int ttlSliding = 20; + const int ttlAbsolute = 500; + string etag = "etag"; + CosmosCacheSession existingSession = new CosmosCacheSession(); + existingSession.SessionKey = "key"; + existingSession.Content = new byte[0]; + existingSession.IsSlidingExpiration = true; + existingSession.TimeToLive = ttlSliding; + existingSession.AbsoluteSlidingExpiration = DateTimeOffset.UtcNow.AddMilliseconds(ttlAbsolute).ToUnixTimeSeconds(); + Mock> mockedItemResponse = new Mock>(); + Mock mockedClient = new Mock(); + Mock mockedContainer = new Mock(); + Mock mockedDatabase = new Mock(); + Mock mockedResponse = new Mock(); + mockedItemResponse.Setup(c => c.Resource).Returns(existingSession); + mockedItemResponse.Setup(c => c.ETag).Returns(etag); + mockedResponse.Setup(c => c.StatusCode).Returns(HttpStatusCode.OK); + mockedContainer.Setup(c => c.ReadContainerAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockedResponse.Object); + mockedContainer.Setup(c => c.ReadItemAsync(It.Is(id => id == "key"), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(mockedItemResponse.Object); + mockedClient.Setup(c => c.GetContainer(It.IsAny(), It.IsAny())).Returns(mockedContainer.Object); + mockedClient.Setup(c => c.GetDatabase(It.IsAny())).Returns(mockedDatabase.Object); + mockedClient.Setup(x => x.Endpoint).Returns(new Uri("http://localhost")); + CosmosCache cache = new CosmosCache(Options.Create(new CosmosCacheOptions() + { + DatabaseName = "something", + ContainerName = "something", + CreateIfNotExists = true, + CosmosClient = mockedClient.Object + })); + + Assert.NotNull(await cache.GetAsync("key")); + // Checks for Db existence due to CreateIfNotExists + mockedClient.Verify(c => c.CreateDatabaseIfNotExistsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + mockedContainer.Verify(c => c.ReadItemAsync(It.Is(id => id == "key"), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + mockedContainer.Verify(c => c.ReplaceItemAsync(It.Is(item => item.TimeToLive == ttlSliding), It.Is(id => id == "key"), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + private class DiagnosticsSink { private List capturedDiagnostics = new List();