diff --git a/src/Momento.Sdk/CacheClient.cs b/src/Momento.Sdk/CacheClient.cs index 5bcd55d2..049694e4 100644 --- a/src/Momento.Sdk/CacheClient.cs +++ b/src/Momento.Sdk/CacheClient.cs @@ -184,6 +184,44 @@ public async Task UpdateTtlAsync(string cacheName, strin return await this.DataClient.UpdateTtlAsync(cacheName, key, ttl); } + /// + public async Task ItemGetTtlAsync(string cacheName, byte[] key) + { + try + { + Utils.ArgumentNotNull(cacheName, nameof(cacheName)); + Utils.ArgumentNotNull(key, nameof(key)); + } + catch (ArgumentNullException e) + { + return new CacheItemGetTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + catch (ArgumentOutOfRangeException e) + { + return new CacheItemGetTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + return await this.DataClient.ItemGetTtlAsync(cacheName, key); + } + + /// + public async Task ItemGetTtlAsync(string cacheName, string key) + { + try + { + Utils.ArgumentNotNull(cacheName, nameof(cacheName)); + Utils.ArgumentNotNull(key, nameof(key)); + } + catch (ArgumentNullException e) + { + return new CacheItemGetTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + catch (ArgumentOutOfRangeException e) + { + return new CacheItemGetTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + return await this.DataClient.ItemGetTtlAsync(cacheName, key); + } + /// public async Task SetAsync(string cacheName, byte[] key, byte[] value, TimeSpan? ttl = null) { diff --git a/src/Momento.Sdk/ICacheClient.cs b/src/Momento.Sdk/ICacheClient.cs index 343bc876..ada9b5a4 100644 --- a/src/Momento.Sdk/ICacheClient.cs +++ b/src/Momento.Sdk/ICacheClient.cs @@ -172,6 +172,17 @@ public interface ICacheClient : IDisposable /// public Task UpdateTtlAsync(string cacheName, string key, TimeSpan ttl); + /// + /// Get the TTL of a cache item. + /// + /// The name of the cache to perform the lookup in. + /// The key to get the TTL for. + /// Task representing the result of the item get TTL operation. + public Task ItemGetTtlAsync(string cacheName, byte[] key); + + /// + public Task ItemGetTtlAsync(string cacheName, string key); + /// /// Set the value in cache with a given time to live (TTL) seconds. /// @@ -531,7 +542,7 @@ public interface ICacheClient : IDisposable /// The set to fetch. /// Task representing with the status of the fetch operation and the associated set. public Task SetFetchAsync(string cacheName, string setName); - + /// /// Calculate the length of a set in the cache. /// diff --git a/src/Momento.Sdk/Internal/DataGrpcManager.cs b/src/Momento.Sdk/Internal/DataGrpcManager.cs index 6a471ea3..fc3fad3e 100644 --- a/src/Momento.Sdk/Internal/DataGrpcManager.cs +++ b/src/Momento.Sdk/Internal/DataGrpcManager.cs @@ -24,6 +24,7 @@ public interface IDataClient { public Task<_KeysExistResponse> KeysExistAsync(_KeysExistRequest request, CallOptions callOptions); public Task<_UpdateTtlResponse> UpdateTtlAsync(_UpdateTtlRequest request, CallOptions callOptions); + public Task<_ItemGetTtlResponse> ItemGetTtlAsync(_ItemGetTtlRequest request, CallOptions callOptions); public Task<_GetResponse> GetAsync(_GetRequest request, CallOptions callOptions); public Task<_SetResponse> SetAsync(_SetRequest request, CallOptions callOptions); public Task<_DeleteResponse> DeleteAsync(_DeleteRequest request, CallOptions callOptions); @@ -84,6 +85,12 @@ public async Task<_UpdateTtlResponse> UpdateTtlAsync(_UpdateTtlRequest request, return await wrapped.ResponseAsync; } + public async Task<_ItemGetTtlResponse> ItemGetTtlAsync(_ItemGetTtlRequest request, CallOptions callOptions) + { + var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.ItemGetTtlAsync(r, o)); + return await wrapped.ResponseAsync; + } + public async Task<_DeleteResponse> DeleteAsync(_DeleteRequest request, CallOptions callOptions) { var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.DeleteAsync(r, o)); diff --git a/src/Momento.Sdk/Internal/ScsDataClient.cs b/src/Momento.Sdk/Internal/ScsDataClient.cs index fb89fcce..b120cf5b 100644 --- a/src/Momento.Sdk/Internal/ScsDataClient.cs +++ b/src/Momento.Sdk/Internal/ScsDataClient.cs @@ -106,6 +106,16 @@ public async Task UpdateTtlAsync(string cacheName, strin return await this.SendUpdateTtlAsync(cacheName, key: key.ToByteString(), ttl: ttl); } + public async Task ItemGetTtlAsync(string cacheName, byte[] key) + { + return await this.SendItemGetTtlAsync(cacheName, key: key.ToByteString()); + } + + public async Task ItemGetTtlAsync(string cacheName, string key) + { + return await this.SendItemGetTtlAsync(cacheName, key: key.ToByteString()); + } + public async Task SetAsync(string cacheName, byte[] key, byte[] value, TimeSpan? ttl = null) { return await this.SendSetAsync(cacheName, key: key.ToByteString(), value: value.ToByteString(), ttl: ttl); @@ -307,7 +317,7 @@ public async Task SetFetchAsync(string cacheName, string { return await SendSetFetchAsync(cacheName, setName); } - + public async Task SetLengthAsync(string cacheName, string setName) { return await SendSetLengthAsync(cacheName, setName); @@ -468,6 +478,37 @@ private async Task SendUpdateTtlAsync(string cacheName, } } + const string REQUEST_TYPE_ITEM_GET_TTL = "ITEM_GET_TTL"; + private async Task SendItemGetTtlAsync(string cacheName, ByteString key) + { + _ItemGetTtlRequest request = new _ItemGetTtlRequest() { CacheKey = key }; + _ItemGetTtlResponse response; + var metadata = MetadataWithCache(cacheName); + try + { + this._logger.LogTraceExecutingRequest(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, null); + response = await this.grpcManager.Client.ItemGetTtlAsync(request, new CallOptions(headers: metadata, deadline: CalculateDeadline())); + } + catch (Exception e) + { + return this._logger.LogTraceRequestError(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, null, new CacheItemGetTtlResponse.Error(_exceptionMapper.Convert(e, metadata))); + } + + if (response.ResultCase == _ItemGetTtlResponse.ResultOneofCase.Missing) + { + return this._logger.LogTraceRequestSuccess(REQUEST_TYPE_ITEM_GET_TTL, cacheName, key, null, null, new CacheItemGetTtlResponse.Miss()); + } + else if (response.ResultCase == _ItemGetTtlResponse.ResultOneofCase.Found) + { + return this._logger.LogTraceRequestSuccess(REQUEST_TYPE_ITEM_GET_TTL, cacheName, key, null, null, new CacheItemGetTtlResponse.Hit(response)); + } + else + { + return this._logger.LogTraceRequestError(REQUEST_TYPE_ITEM_GET_TTL, cacheName, key, null, null, new CacheItemGetTtlResponse.Error( + _exceptionMapper.Convert(new Exception("Unknown response type"), metadata))); + } + } + const string REQUEST_TYPE_SET = "SET"; private async Task SendSetAsync(string cacheName, ByteString key, ByteString value, TimeSpan? ttl = null) { @@ -939,7 +980,7 @@ private async Task SendSetFetchAsync(string cacheName, st return this._logger.LogTraceCollectionRequestSuccess(REQUEST_TYPE_SET_FETCH, cacheName, setName, new CacheSetFetchResponse.Miss()); } - + const string REQUEST_TYPE_SET_LENGTH = "SET_LENGTH"; private async Task SendSetLengthAsync(string cacheName, string setName) { diff --git a/src/Momento.Sdk/Responses/CacheItemGetTtlResponse.cs b/src/Momento.Sdk/Responses/CacheItemGetTtlResponse.cs new file mode 100644 index 00000000..dd6bdd7b --- /dev/null +++ b/src/Momento.Sdk/Responses/CacheItemGetTtlResponse.cs @@ -0,0 +1,103 @@ +namespace Momento.Sdk.Responses; + +using System; +using Momento.Protos.CacheClient; +using Momento.Sdk.Exceptions; + +/// +/// Parent response type for a cache item get ttl request. The +/// response object is resolved to a type-safe object of one of +/// the following subtypes: +/// +/// CacheItemGetTtlResponse.Hit +/// CacheItemGetTtlResponse.Miss +/// CacheItemGetTtlResponse.Error +/// +/// Pattern matching can be used to operate on the appropriate subtype. +/// For example: +/// +/// if (response is CacheItemGetTtlResponse.Hit hitResponse) +/// { +/// // handle ttl value as appropriate +/// } +/// else if (response is CacheItemGetTtlResponse.Miss missResponse) +/// { +/// // handle key was not found as appropriate +/// } +/// else if (response is CacheItemGetTtlResponse.Error errorResponse) +/// { +/// // handle error as appropriate +/// } +/// else +/// { +/// // handle unexpected response +/// } +/// +/// +public abstract class CacheItemGetTtlResponse +{ + /// + /// Indicates the key was found in the cache and the ttl was returned. + /// + public class Hit : CacheItemGetTtlResponse { + /// + /// The value of the ttl. + /// + protected readonly ulong value; + + /// + public Hit(_ItemGetTtlResponse response) + { + value = response.Found.RemainingTtlMillis; + } + + /// + /// The value of the ttl. + /// + public TimeSpan Value + { + get => TimeSpan.FromMilliseconds(value); + } + + /// + public override string ToString() + { + return $"{base.ToString()}: Value: {Value}"; + } + } + + /// + /// Indicates the key was not found in the cache, hence the ttl was not returned. + /// + public class Miss : CacheItemGetTtlResponse { } + + /// + public class Error : CacheItemGetTtlResponse, IError + { + private readonly SdkException _error; + + /// + public Error(SdkException error) + { + _error = error; + } + + /// + public SdkException InnerException + { + get => _error; + } + + /// + public MomentoErrorCode ErrorCode + { + get => _error.ErrorCode; + } + + /// + public string Message + { + get => _error.Message; + } + } +} diff --git a/tests/Integration/Momento.Sdk.Tests/CacheDataTest.cs b/tests/Integration/Momento.Sdk.Tests/CacheDataTest.cs index 09794630..b45259e1 100644 --- a/tests/Integration/Momento.Sdk.Tests/CacheDataTest.cs +++ b/tests/Integration/Momento.Sdk.Tests/CacheDataTest.cs @@ -167,7 +167,6 @@ public async Task KeysExistAsync_String() Assert.Equal(expectedDict, goodResponse.ExistsDictionary); } - [Theory] [InlineData(null, new byte[] { 0x00 }, new byte[] { 0x00 })] [InlineData("cache", null, new byte[] { 0x00 })] diff --git a/tests/Integration/Momento.Sdk.Tests/TtlTest.cs b/tests/Integration/Momento.Sdk.Tests/TtlTest.cs index 3fe0dd2a..cd55c705 100644 --- a/tests/Integration/Momento.Sdk.Tests/TtlTest.cs +++ b/tests/Integration/Momento.Sdk.Tests/TtlTest.cs @@ -110,4 +110,50 @@ public async Task UpdateTtlAsync_KeyIsStringAndExists_IsSet() Assert.True(existsResponse is CacheKeyExistsResponse.Success, "exists response should be success"); Assert.False(((CacheKeyExistsResponse.Success)existsResponse).Exists, "Key should not exist"); } + + [Fact] + public async Task ItemGetTtl_HappyPath() + { + // Add an item with a minute ttl + string key = Utils.NewGuidString(); + var ttl = TimeSpan.FromSeconds(60); + var response = await client.SetAsync(cacheName, key, Utils.NewGuidString(), ttl); + Assert.True(response is CacheSetResponse.Success, $"Unexpected response: {response}"); + + var ttlResponse = await client.ItemGetTtlAsync(cacheName, key); + Assert.True(ttlResponse is CacheItemGetTtlResponse.Hit, $"Unexpected response: {ttlResponse}"); + var theTtl = ((CacheItemGetTtlResponse.Hit)ttlResponse).Value; + Assert.True(theTtl.TotalMilliseconds < 60000 && theTtl.TotalMilliseconds > 55000); + + await Task.Delay(1000); + + var ttlResponse2 = await client.ItemGetTtlAsync(cacheName, key); + Assert.True(ttlResponse2 is CacheItemGetTtlResponse.Hit, $"Unexpected response: {ttlResponse}"); + var theTtl2 = ((CacheItemGetTtlResponse.Hit)ttlResponse2).Value; + + Assert.True(theTtl2.TotalMilliseconds <= theTtl.TotalMilliseconds - 1000); + } + + [Fact] + public async Task ItemGetTtl_Miss() + { + var ttlResponse = await client.ItemGetTtlAsync(cacheName, Utils.NewGuidString()); + Assert.True(ttlResponse is CacheItemGetTtlResponse.Miss, $"Unexpected response: {ttlResponse}"); + } + + [Fact] + public async Task ItemGetTtl_NonexistentCacheError() + { + var ttlResponse = await client.ItemGetTtlAsync(Utils.NewGuidString(), Utils.NewGuidString()); + Assert.True(ttlResponse is CacheItemGetTtlResponse.Error, $"Unexpected response: {ttlResponse}"); + Assert.Equal(MomentoErrorCode.NOT_FOUND_ERROR, ((CacheItemGetTtlResponse.Error)ttlResponse).ErrorCode); + } + + [Fact] + public async Task ItemGetTtl_EmptyCacheNameError() + { + var ttlResponse = await client.ItemGetTtlAsync(null!, Utils.NewGuidString()); + Assert.True(ttlResponse is CacheItemGetTtlResponse.Error, $"Unexpected response: {ttlResponse}"); + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, ((CacheItemGetTtlResponse.Error)ttlResponse).ErrorCode); + } }