From 046ebb58623872ec0d10deba8c7e259a618d8a72 Mon Sep 17 00:00:00 2001 From: Michael Landis Date: Thu, 4 May 2023 15:53:35 -0700 Subject: [PATCH] feat: implement `UpdateTtl` command (#438) This adds the command UpdateTtl, which unconditionally overwrites the TTL of an item in the cache. Bumps protos version; adds the command, documentation, and integration tests. --- src/Momento.Sdk/CacheClient.cs | 47 +++++++- src/Momento.Sdk/ICacheClient.cs | 11 ++ src/Momento.Sdk/Internal/DataGrpcManager.cs | 7 ++ src/Momento.Sdk/Internal/ScsDataClient.cs | 50 +++++++- src/Momento.Sdk/Momento.Sdk.csproj | 2 +- .../Responses/CacheUpdateTtlResponse.cs | 78 ++++++++++++ .../Integration/Momento.Sdk.Tests/TtlTest.cs | 113 ++++++++++++++++++ 7 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 src/Momento.Sdk/Responses/CacheUpdateTtlResponse.cs create mode 100644 tests/Integration/Momento.Sdk.Tests/TtlTest.cs diff --git a/src/Momento.Sdk/CacheClient.cs b/src/Momento.Sdk/CacheClient.cs index 1f832a01..9f2d837e 100644 --- a/src/Momento.Sdk/CacheClient.cs +++ b/src/Momento.Sdk/CacheClient.cs @@ -84,7 +84,7 @@ public async Task ListCachesAsync() } /// - async Task ICacheClient.KeyExistsAsync(string cacheName, byte[] key) + public async Task KeyExistsAsync(string cacheName, byte[] key) { try { @@ -99,7 +99,7 @@ async Task ICacheClient.KeyExistsAsync(string cacheName, } /// - async Task ICacheClient.KeyExistsAsync(string cacheName, string key) + public async Task KeyExistsAsync(string cacheName, string key) { try { @@ -114,7 +114,7 @@ async Task ICacheClient.KeyExistsAsync(string cacheName, } /// - async Task ICacheClient.KeysExistAsync(string cacheName, IEnumerable keys) + public async Task KeysExistAsync(string cacheName, IEnumerable keys) { try { @@ -130,7 +130,7 @@ async Task ICacheClient.KeysExistAsync(string cacheName, } /// - async Task ICacheClient.KeysExistAsync(string cacheName, IEnumerable keys) + public async Task KeysExistAsync(string cacheName, IEnumerable keys) { try { @@ -145,6 +145,45 @@ async Task ICacheClient.KeysExistAsync(string cacheName, return await this.DataClient.KeysExistAsync(cacheName, keys); } + /// + public async Task UpdateTtlAsync(string cacheName, byte[] key, TimeSpan ttl) + { + try + { + Utils.ArgumentNotNull(cacheName, nameof(cacheName)); + Utils.ArgumentNotNull(key, nameof(key)); + Utils.ArgumentStrictlyPositive(ttl, nameof(ttl)); + } + catch (ArgumentNullException e) + { + return new CacheUpdateTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + catch (ArgumentOutOfRangeException e) + { + return new CacheUpdateTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + return await this.DataClient.UpdateTtlAsync(cacheName, key, ttl); + } + + /// + public async Task UpdateTtlAsync(string cacheName, string key, TimeSpan ttl) + { + try + { + Utils.ArgumentNotNull(cacheName, nameof(cacheName)); + Utils.ArgumentNotNull(key, nameof(key)); + Utils.ArgumentStrictlyPositive(ttl, nameof(ttl)); + } + catch (ArgumentNullException e) + { + return new CacheUpdateTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + catch (ArgumentOutOfRangeException e) + { + return new CacheUpdateTtlResponse.Error(new InvalidArgumentException(e.Message)); + } + return await this.DataClient.UpdateTtlAsync(cacheName, key, ttl); + } /// 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 b333bc1a..305a6610 100644 --- a/src/Momento.Sdk/ICacheClient.cs +++ b/src/Momento.Sdk/ICacheClient.cs @@ -160,6 +160,17 @@ public interface ICacheClient : IDisposable /// public Task KeysExistAsync(string cacheName, IEnumerable keys); + /// + /// Overwrites the TTL of a cache item with the provided TTL. + /// + /// The name of the cache to perform the lookup in. + /// The key to update the TTL for. + /// The new TTL for the item. + /// Task representing the result of the update TTL operation. + public Task UpdateTtlAsync(string cacheName, byte[] key, TimeSpan ttl); + + /// + public Task UpdateTtlAsync(string cacheName, string key, TimeSpan ttl); /// /// Set the value in cache with a given time to live (TTL) seconds. diff --git a/src/Momento.Sdk/Internal/DataGrpcManager.cs b/src/Momento.Sdk/Internal/DataGrpcManager.cs index 65e03504..4f6a54bd 100644 --- a/src/Momento.Sdk/Internal/DataGrpcManager.cs +++ b/src/Momento.Sdk/Internal/DataGrpcManager.cs @@ -23,6 +23,7 @@ namespace Momento.Sdk.Internal; public interface IDataClient { public Task<_KeysExistResponse> KeysExistAsync(_KeysExistRequest request, CallOptions callOptions); + public Task<_UpdateTtlResponse> UpdateTtlAsync(_UpdateTtlRequest 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); @@ -74,6 +75,12 @@ public async Task<_KeysExistResponse> KeysExistAsync(_KeysExistRequest request, return await wrapped.ResponseAsync; } + public async Task<_UpdateTtlResponse> UpdateTtlAsync(_UpdateTtlRequest request, CallOptions callOptions) + { + var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.UpdateTtlAsync(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 7a98f789..e97027eb 100644 --- a/src/Momento.Sdk/Internal/ScsDataClient.cs +++ b/src/Momento.Sdk/Internal/ScsDataClient.cs @@ -96,6 +96,16 @@ public async Task KeysExistAsync(string cacheName, IEnum return await this.SendKeysExistAsync(cacheName, keys: keys.ToEnumerableByteString()); } + public async Task UpdateTtlAsync(string cacheName, byte[] key, TimeSpan ttl) + { + return await this.SendUpdateTtlAsync(cacheName, key: key.ToByteString(), ttl: ttl); + } + + public async Task UpdateTtlAsync(string cacheName, string key, TimeSpan ttl) + { + return await this.SendUpdateTtlAsync(cacheName, key: key.ToByteString(), ttl: ttl); + } + 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); @@ -412,6 +422,42 @@ private async Task SendKeysExistAsync(string cacheName, return this._logger.LogTraceRequestSuccess(REQUEST_TYPE_KEYS_EXIST, cacheName, keys.ToString().ToByteString(), null, null, new CacheKeysExistResponse.Success(keys, response)); } + const string REQUEST_TYPE_UPDATE_TTL = "UPDATE_TTL"; + private async Task SendUpdateTtlAsync(string cacheName, ByteString key, TimeSpan ttl) + { + _UpdateTtlRequest request = new _UpdateTtlRequest() + { + CacheKey = key, + OverwriteToMilliseconds = TtlToMilliseconds(ttl) + }; + _UpdateTtlResponse response; + var metadata = MetadataWithCache(cacheName); + try + { + this._logger.LogTraceExecutingRequest(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, ttl); + response = await this.grpcManager.Client.UpdateTtlAsync(request, new CallOptions(headers: metadata, deadline: CalculateDeadline())); + } + catch (Exception e) + { + return this._logger.LogTraceRequestError(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, ttl, new CacheUpdateTtlResponse.Error(_exceptionMapper.Convert(e, metadata))); + } + + if (response.ResultCase == _UpdateTtlResponse.ResultOneofCase.Set) + { + return this._logger.LogTraceRequestSuccess(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, ttl, new CacheUpdateTtlResponse.Set()); + } + else if (response.ResultCase == _UpdateTtlResponse.ResultOneofCase.Missing) + { + return this._logger.LogTraceRequestSuccess(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, ttl, new CacheUpdateTtlResponse.Miss()); + } + else + { + // The other alternative is "NotSet", which is impossible when doing OverwriteTtl. + return this._logger.LogTraceRequestError(REQUEST_TYPE_UPDATE_TTL, cacheName, key, null, ttl, new CacheUpdateTtlResponse.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) { @@ -1058,7 +1104,7 @@ private async Task SendListFetchAsync(string cacheName, return this._logger.LogTraceCollectionRequestSuccess(REQUEST_TYPE_LIST_FETCH, cacheName, listName, new CacheListFetchResponse.Miss()); } - + const string REQUEST_TYPE_LIST_RETAIN = "LIST_RETAIN"; private async Task SendListRetainAsync(string cacheName, string listName, int? startIndex, int? endIndex) { @@ -1095,7 +1141,7 @@ private async Task SendListRetainAsync(string cacheName return this._logger.LogTraceCollectionRequestSuccess(REQUEST_TYPE_LIST_RETAIN, cacheName, listName, new CacheListRetainResponse.Success()); } - + const string REQUEST_TYPE_LIST_REMOVE_VALUE = "LIST_REMOVE_VALUE"; private async Task SendListRemoveValueAsync(string cacheName, string listName, ByteString value) { diff --git a/src/Momento.Sdk/Momento.Sdk.csproj b/src/Momento.Sdk/Momento.Sdk.csproj index 32e77926..f15d9499 100644 --- a/src/Momento.Sdk/Momento.Sdk.csproj +++ b/src/Momento.Sdk/Momento.Sdk.csproj @@ -54,7 +54,7 @@ - + diff --git a/src/Momento.Sdk/Responses/CacheUpdateTtlResponse.cs b/src/Momento.Sdk/Responses/CacheUpdateTtlResponse.cs new file mode 100644 index 00000000..8b148452 --- /dev/null +++ b/src/Momento.Sdk/Responses/CacheUpdateTtlResponse.cs @@ -0,0 +1,78 @@ +namespace Momento.Sdk.Responses; + +using Momento.Sdk.Exceptions; + + +/// +/// Parent response type for a cache update ttl request. The +/// response object is resolved to a type-safe object of one of +/// the following subtypes: +/// +/// CacheUpdateTtlResponse.Set +/// CacheUpdateTtlResponse.Miss +/// CacheUpdateTtlResponse.Error +/// +/// Pattern matching can be used to operate on the appropriate subtype. +/// For example: +/// +/// if (response is CacheUpdateTtlResponse.Set setResponse) +/// { +/// // handle ttl updated as appropriate +/// } +/// else if (response is CacheUpdateTtlResponse.Miss missResponse) +/// { +/// // handle ttl not updated because key was not found as appropriate +/// } +/// else if (response is CacheUpdateTtlResponse.Error errorResponse) +/// { +/// // handle error as appropriate +/// return errorResponse.Message; +/// } +/// else +/// { +/// // handle unexpected response +/// } +/// +/// +public abstract class CacheUpdateTtlResponse +{ + /// + /// Indicates the key was found in the cache and the ttl was updated. + /// + public class Set : CacheUpdateTtlResponse { } + + /// + /// Indicates the key was not found in the cache, hence the ttl was not updated. + /// + public class Miss : CacheUpdateTtlResponse { } + + /// + public class Error : CacheUpdateTtlResponse, 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/TtlTest.cs b/tests/Integration/Momento.Sdk.Tests/TtlTest.cs new file mode 100644 index 00000000..395adcb8 --- /dev/null +++ b/tests/Integration/Momento.Sdk.Tests/TtlTest.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Momento.Sdk.Internal.ExtensionMethods; + +namespace Momento.Sdk.Tests; + +[Collection("CacheClient")] +public class TtlTest : TestBase +{ + // Test initialization + public TtlTest(CacheClientFixture fixture) : base(fixture) + { + } + + + [Theory] + [InlineData(null, new byte[] { 0x00 }, 60)] + [InlineData("cache", null, 60)] + [InlineData(null, new byte[] { 0x00 }, -1)] + public async Task UpdateTtlAsync_NullChecksByteArray_IsError(string cacheName, byte[] key, int ttlSeconds) + { + var ttl = TimeSpan.FromSeconds(ttlSeconds); + var response = await client.UpdateTtlAsync(cacheName, key, ttl); + Assert.True(response is CacheUpdateTtlResponse.Error, $"Unexpected response: {response}"); + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, ((CacheUpdateTtlResponse.Error)response).ErrorCode); + } + + [Theory] + [InlineData(null, "key", 60)] + [InlineData("cache", null, 60)] + [InlineData(null, "key", -1)] + public async Task UpdateTtlAsync_NullChecksString_IsError(string cacheName, string key, int ttlSeconds) + { + var ttl = TimeSpan.FromSeconds(ttlSeconds); + var response = await client.UpdateTtlAsync(cacheName, key, ttl); + Assert.True(response is CacheUpdateTtlResponse.Error, $"Unexpected response: {response}"); + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, ((CacheUpdateTtlResponse.Error)response).ErrorCode); + } + + [Fact] + public async Task UpdateTtlAsync_KeyIsByteArrayAndMissing_IsMiss() + { + byte[] key = Utils.NewGuidByteArray(); + var ttl = TimeSpan.FromSeconds(60); + var response = await client.UpdateTtlAsync(cacheName, key, ttl); + Assert.True(response is CacheUpdateTtlResponse.Miss, $"Unexpected response: {response}"); + } + + [Fact] + public async Task UpdateTtlAsync_KeyIsStringAndMissing_IsMiss() + { + string key = Utils.NewGuidString(); + var ttl = TimeSpan.FromSeconds(60); + var response = await client.UpdateTtlAsync(cacheName, key, ttl); + Assert.True(response is CacheUpdateTtlResponse.Miss, $"Unexpected response: {response}"); + } + + [Fact] + public async Task UpdateTtlAsync_KeyIsByteArrayAndExists_IsSet() + { + // Add an item with a minute ttl + byte[] key = Utils.NewGuidByteArray(); + var ttl = TimeSpan.FromSeconds(60); + var response = await client.SetAsync(cacheName, key, Utils.NewGuidByteArray()); + Assert.True(response is CacheSetResponse.Success, $"Unexpected response: {response}"); + + // Check it is there + var existsResponse = await client.KeyExistsAsync(cacheName, key); + Assert.True(existsResponse is CacheKeyExistsResponse.Success, "exists response should be success"); + Assert.True(((CacheKeyExistsResponse.Success)existsResponse).Exists, "Key should exist"); + + // Let's make the TTL really small. + var updateTtlResponse = await client.UpdateTtlAsync(cacheName, key, TimeSpan.FromSeconds(1)); + Assert.True(updateTtlResponse is CacheUpdateTtlResponse.Set, $"UpdateTtl call should have been Set but was: {response}"); + + // Wait for the TTL to expire + await Task.Delay(1000); + + // Check it is gone + existsResponse = await client.KeyExistsAsync(cacheName, key); + 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 UpdateTtlAsync_KeyIsStringAndExists_IsSet() + { + // 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()); + Assert.True(response is CacheSetResponse.Success, $"Unexpected response: {response}"); + + // Check it is there + var existsResponse = await client.KeyExistsAsync(cacheName, key); + Assert.True(existsResponse is CacheKeyExistsResponse.Success, "exists response should be success"); + Assert.True(((CacheKeyExistsResponse.Success)existsResponse).Exists, "Key should exist"); + + // Let's make the TTL really small. + var updateTtlResponse = await client.UpdateTtlAsync(cacheName, key, TimeSpan.FromSeconds(1)); + Assert.True(updateTtlResponse is CacheUpdateTtlResponse.Set, $"UpdateTtl call should have been Set but was: {response}"); + + // Wait for the TTL to expire + await Task.Delay(1000); + + // Check it is gone + existsResponse = await client.KeyExistsAsync(cacheName, key); + Assert.True(existsResponse is CacheKeyExistsResponse.Success, "exists response should be success"); + Assert.False(((CacheKeyExistsResponse.Success)existsResponse).Exists, "Key should not exist"); + } +}