diff --git a/src/Momento.Sdk/CacheClient.cs b/src/Momento.Sdk/CacheClient.cs index 58457ab5..0e0bf4b1 100644 --- a/src/Momento.Sdk/CacheClient.cs +++ b/src/Momento.Sdk/CacheClient.cs @@ -976,6 +976,27 @@ public async Task SetFetchAsync(string cacheName, string return await this.DataClient.SetFetchAsync(cacheName, setName); } + + /// + public async Task SetSampleAsync(string cacheName, string setName, int limit) + { + try + { + Utils.ArgumentNotNull(cacheName, nameof(cacheName)); + Utils.ArgumentNotNull(setName, nameof(setName)); + Utils.ArgumentNonNegative(limit, nameof(limit)); + } + catch (ArgumentNullException e) + { + return new CacheSetSampleResponse.Error(new InvalidArgumentException(e.Message)); + } + catch (ArgumentOutOfRangeException e) + { + return new CacheSetSampleResponse.Error(new InvalidArgumentException(e.Message)); + } + + return await this.DataClient.SetSampleAsync(cacheName, setName, Convert.ToUInt64(limit)); + } /// public async Task SetLengthAsync(string cacheName, string setName) diff --git a/src/Momento.Sdk/ICacheClient.cs b/src/Momento.Sdk/ICacheClient.cs index ada9b5a4..4afa0459 100644 --- a/src/Momento.Sdk/ICacheClient.cs +++ b/src/Momento.Sdk/ICacheClient.cs @@ -432,7 +432,7 @@ public interface ICacheClient : IDisposable /// /// Name of the cache to perform the lookup in. /// The dictionary to fetch. - /// Task representing with the status of the fetch operation and the associated dictionary. + /// Task representing the status of the fetch operation and the associated dictionary. public Task DictionaryFetchAsync(string cacheName, string dictionaryName); /// @@ -540,9 +540,18 @@ public interface ICacheClient : IDisposable /// /// Name of the cache to perform the lookup in. /// The set to fetch. - /// Task representing with the status of the fetch operation and the associated set. + /// Task representing the status of the fetch operation and the associated set. public Task SetFetchAsync(string cacheName, string setName); - + + /// + /// Fetch a random sample of elements from the set. Returns a different random sample for each call. + /// + /// Name of the cache to perform the lookup in. + /// The set to fetch. + /// The maximum number of elements to return. If the set contains fewer than 'limit' elements, the entire set will be returned. + /// Task representing the status of the sample operation and the associated set. + public Task SetSampleAsync(string cacheName, string setName, int limit); + /// /// Calculate the length of a set in the cache. /// @@ -634,7 +643,7 @@ public interface ICacheClient : IDisposable /// The list to fetch. /// Start inclusive index for fetch operation. Must be smaller than the endIndex. /// End exclusive index for fetch operation. Must be larger than the startIndex. - /// Task representing with the status of the fetch operation and the associated list. + /// Task representing the status of the fetch operation and the associated list. public Task ListFetchAsync(string cacheName, string listName, int? startIndex = null, int? endIndex = null); /// diff --git a/src/Momento.Sdk/Internal/DataGrpcManager.cs b/src/Momento.Sdk/Internal/DataGrpcManager.cs index fc3fad3e..61e36f47 100644 --- a/src/Momento.Sdk/Internal/DataGrpcManager.cs +++ b/src/Momento.Sdk/Internal/DataGrpcManager.cs @@ -40,6 +40,7 @@ public interface IDataClient public Task<_SetUnionResponse> SetUnionAsync(_SetUnionRequest request, CallOptions callOptions); public Task<_SetDifferenceResponse> SetDifferenceAsync(_SetDifferenceRequest request, CallOptions callOptions); public Task<_SetFetchResponse> SetFetchAsync(_SetFetchRequest request, CallOptions callOptions); + public Task<_SetSampleResponse> SetSampleAsync(_SetSampleRequest request, CallOptions callOptions); public Task<_SetLengthResponse> SetLengthAsync(_SetLengthRequest request, CallOptions callOptions); public Task<_ListPushFrontResponse> ListPushFrontAsync(_ListPushFrontRequest request, CallOptions callOptions); public Task<_ListPushBackResponse> ListPushBackAsync(_ListPushBackRequest request, CallOptions callOptions); @@ -174,6 +175,12 @@ public async Task<_SetFetchResponse> SetFetchAsync(_SetFetchRequest request, Cal var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.SetFetchAsync(r, o)); return await wrapped.ResponseAsync; } + + public async Task<_SetSampleResponse> SetSampleAsync(_SetSampleRequest request, CallOptions callOptions) + { + var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.SetSampleAsync(r, o)); + return await wrapped.ResponseAsync; + } public async Task<_SetLengthResponse> SetLengthAsync(_SetLengthRequest request, CallOptions callOptions) { diff --git a/src/Momento.Sdk/Internal/ScsDataClient.cs b/src/Momento.Sdk/Internal/ScsDataClient.cs index b120cf5b..3750f1ab 100644 --- a/src/Momento.Sdk/Internal/ScsDataClient.cs +++ b/src/Momento.Sdk/Internal/ScsDataClient.cs @@ -318,6 +318,11 @@ public async Task SetFetchAsync(string cacheName, string return await SendSetFetchAsync(cacheName, setName); } + public async Task SetSampleAsync(string cacheName, string setName, ulong limit) + { + return await SendSetSampleAsync(cacheName, setName, limit); + } + public async Task SetLengthAsync(string cacheName, string setName) { return await SendSetLengthAsync(cacheName, setName); @@ -980,7 +985,32 @@ 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_SAMPLE = "SET_SAMPLE"; + private async Task SendSetSampleAsync(string cacheName, string setName, ulong limit) + { + _SetSampleRequest request = new() { SetName = setName.ToByteString(), Limit = limit }; + _SetSampleResponse response; + var metadata = MetadataWithCache(cacheName); + try + { + this._logger.LogTraceExecutingCollectionRequest(REQUEST_TYPE_SET_SAMPLE, cacheName, setName); + response = await this.grpcManager.Client.SetSampleAsync(request, new CallOptions(headers: MetadataWithCache(cacheName), deadline: CalculateDeadline())); + } + catch (Exception e) + { + return this._logger.LogTraceCollectionRequestError(REQUEST_TYPE_SET_SAMPLE, cacheName, setName, new CacheSetSampleResponse.Error(_exceptionMapper.Convert(e, metadata))); + } + if (response.SetCase == _SetSampleResponse.SetOneofCase.Found) + { + return this._logger.LogTraceCollectionRequestSuccess(REQUEST_TYPE_SET_SAMPLE, cacheName, setName, new CacheSetSampleResponse.Hit(response)); + } + + return this._logger.LogTraceCollectionRequestSuccess(REQUEST_TYPE_SET_SAMPLE, cacheName, setName, new CacheSetSampleResponse.Miss()); + } + const string REQUEST_TYPE_SET_LENGTH = "SET_LENGTH"; private async Task SendSetLengthAsync(string cacheName, string setName) { diff --git a/src/Momento.Sdk/Internal/Utils.cs b/src/Momento.Sdk/Internal/Utils.cs index 4723e0cb..664e8cad 100644 --- a/src/Momento.Sdk/Internal/Utils.cs +++ b/src/Momento.Sdk/Internal/Utils.cs @@ -99,7 +99,27 @@ public static void ArgumentStrictlyPositive(int? argument, string paramName) if (argument <= 0) { - throw new ArgumentOutOfRangeException(paramName, "TimeSpan must be strictly positive."); + throw new ArgumentOutOfRangeException(paramName, "int must be strictly positive."); + } + } + + + /// + /// Throw an exception if the value is negative. + /// + /// The value to test. + /// Name of the value to propagate to the exception. + /// is negative. + public static void ArgumentNonNegative(int? argument, string paramName) + { + if (argument is null) + { + return; + } + + if (argument < 0) + { + throw new ArgumentOutOfRangeException(paramName, "int must be strictly positive."); } } diff --git a/src/Momento.Sdk/Momento.Sdk.csproj b/src/Momento.Sdk/Momento.Sdk.csproj index 82fadc32..e07f9afa 100644 --- a/src/Momento.Sdk/Momento.Sdk.csproj +++ b/src/Momento.Sdk/Momento.Sdk.csproj @@ -57,7 +57,7 @@ - + diff --git a/src/Momento.Sdk/Responses/CacheSetFetchResponse.cs b/src/Momento.Sdk/Responses/CacheSetFetchResponse.cs index 2c6957dd..cdff63a3 100644 --- a/src/Momento.Sdk/Responses/CacheSetFetchResponse.cs +++ b/src/Momento.Sdk/Responses/CacheSetFetchResponse.cs @@ -23,7 +23,7 @@ namespace Momento.Sdk.Responses; /// /// if (response is CacheSetFetchResponse.Hit hitResponse) /// { -/// return hitResponse.ValueSetStringString; +/// return hitResponse.ValueSetString; /// } /// else if (response is CacheSetFetchResponse.Miss missResponse) /// { diff --git a/src/Momento.Sdk/Responses/CacheSetSampleResponse.cs b/src/Momento.Sdk/Responses/CacheSetSampleResponse.cs new file mode 100644 index 00000000..6206daf8 --- /dev/null +++ b/src/Momento.Sdk/Responses/CacheSetSampleResponse.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Google.Protobuf; +using Google.Protobuf.Collections; +using Momento.Protos.CacheClient; +using Momento.Sdk.Exceptions; +using Momento.Sdk.Internal; +using Momento.Sdk.Internal.ExtensionMethods; + +namespace Momento.Sdk.Responses; + +/// +/// Parent response type for a cache set sample request. The +/// response object is resolved to a type-safe object of one of +/// the following subtypes: +/// +/// CacheSetSampleResponse.Success +/// CacheSetSampleResponse.Error +/// +/// Pattern matching can be used to operate on the appropriate subtype. +/// For example: +/// +/// if (response is CacheSetSampleResponse.Hit hitResponse) +/// { +/// return hitResponse.ValueSetString; +/// } +/// else if (response is CacheSetSampleResponse.Miss missResponse) +/// { +/// // handle miss as appropriate +/// } +/// else if (response is CacheSetSampleResponse.Error errorResponse) +/// { +/// // handle error as appropriate +/// } +/// else +/// { +/// // handle unexpected response +/// } +/// +/// +public abstract class CacheSetSampleResponse +{ + /// + public class Hit : CacheSetSampleResponse + { +#pragma warning disable 1591 + protected readonly RepeatedField elements; + protected readonly Lazy> _byteArraySet; + protected readonly Lazy> _stringSet; +#pragma warning restore 1591 + + /// + /// + /// + /// Cache set sample response. + public Hit(_SetSampleResponse response) + { + elements = response.Found.Elements; + _byteArraySet = new(() => + { + return new HashSet( + elements.Select(element => element.ToByteArray()), + Utils.ByteArrayComparer); + }); + + _stringSet = new(() => + { + + return new HashSet(elements.Select(element => element.ToStringUtf8())); + }); + } + + /// + /// Randomly sample elements from the Set as a of arrays. + /// + public ISet ValueSetByteArray { get => _byteArraySet.Value; } + + /// + /// Randomly sample elements from the Set as a of s. + /// + public ISet ValueSetString { get => _stringSet.Value; } + + /// + public override string ToString() + { + var stringRepresentation = String.Join(", ", ValueSetString.Select(value => $"\"{value}\"")); + var byteArrayRepresentation = String.Join(", ", ValueSetByteArray.Select(value => $"\"{value.ToPrettyHexString()}\"")); + return $"{base.ToString()}: ValueSetString: {{{stringRepresentation.Truncate()}}} ValueSetByteArray: {{{byteArrayRepresentation.Truncate()}}}"; + } + } + + /// + public class Miss : CacheSetSampleResponse + { + + } + + /// + public class Error : CacheSetSampleResponse, 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.MessageWrapper}: {_error.Message}"; + } + + /// + public override string ToString() + { + return $"{base.ToString()}: {this.Message}"; + } + } +} diff --git a/tests/Integration/Momento.Sdk.Tests/SetTest.cs b/tests/Integration/Momento.Sdk.Tests/SetTest.cs index b5314041..18741586 100644 --- a/tests/Integration/Momento.Sdk.Tests/SetTest.cs +++ b/tests/Integration/Momento.Sdk.Tests/SetTest.cs @@ -1,16 +1,21 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Momento.Sdk.Requests; using Momento.Sdk.Responses; using Momento.Sdk.Tests; +using Xunit.Abstractions; namespace Momento.Sdk.Tests; [Collection("CacheClient")] public class SetTest : TestBase { - public SetTest(CacheClientFixture fixture) : base(fixture) + private readonly ITestOutputHelper testOutputHelper; + + public SetTest(CacheClientFixture fixture, ITestOutputHelper testOutputHelper) : base(fixture) { + this.testOutputHelper = testOutputHelper; } [Theory] @@ -572,6 +577,63 @@ public async Task SetFetchAsync_UsesCachedStringSet_HappyPath() var set2 = hitResponse.ValueSetString; Assert.Same(set1, set2); } + + [Theory(Skip = "SetSample is a new API, we can't enable these tests until the server changes are deployed")] + [InlineData(null, "my-set", 100)] + [InlineData("cache", null, 100)] + [InlineData("cache", "my-set", -1)] + public async Task SetSampleAsync_NullChecks_IsError(string cacheName, string setName, int limit) + { + CacheSetSampleResponse response = await client.SetSampleAsync(cacheName, setName, limit); + Assert.True(response is CacheSetSampleResponse.Error, $"Unexpected response: {response}"); + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, ((CacheSetSampleResponse.Error)response).ErrorCode); + } + + [Fact(Skip = "SetSample is a new API, we can't enable these tests until the server changes are deployed")] + public async Task SetSampleAsync_Missing_HappyPath() + { + var setName = Utils.NewGuidString(); + CacheSetSampleResponse response = await client.SetSampleAsync(cacheName, setName, 100); + Assert.True(response is CacheSetSampleResponse.Miss, $"Unexpected response: {response}"); + } + + [Fact(Skip = "SetSample is a new API, we can't enable these tests until the server changes are deployed")] + public async Task SetSampleAsync_UsesCachedStringSet_HappyPath() + { + var setName = Utils.NewGuidString(); + var allValues = new HashSet { "jalapeno", "habanero", "serrano", "poblano" }; + CacheSetAddElementsResponse setResponse = await client.SetAddElementsAsync(cacheName, setName, allValues); + Assert.True(setResponse is CacheSetAddElementsResponse.Success, $"Unexpected response: {setResponse}"); + + CacheSetSampleResponse allElementsResponse = await client.SetSampleAsync(cacheName, setName, allValues.Count); + Assert.True(allElementsResponse is CacheSetSampleResponse.Hit, $"Unexpected response: {allElementsResponse}"); + var allElementsHitValues = ((CacheSetSampleResponse.Hit)allElementsResponse).ValueSetString; + Assert.True(allValues.SetEquals(allElementsHitValues), $"Expected sample with with limit matching set size to return the entire set; expected ({String.Join(", ", allValues)}), got ({String.Join(", ", allElementsHitValues)})"); + + CacheSetSampleResponse limitGreaterThanSetSizeResponse = await client.SetSampleAsync(cacheName, setName, 1000); + Assert.True(limitGreaterThanSetSizeResponse is CacheSetSampleResponse.Hit, $"Unexpected response: {limitGreaterThanSetSizeResponse}"); + var limitGreaterThanSetSizeHitValues = ((CacheSetSampleResponse.Hit)limitGreaterThanSetSizeResponse).ValueSetString; + Assert.True(allValues.SetEquals(limitGreaterThanSetSizeHitValues), $"Expected sample with with limit greater than set size to return the entire set; expected ({String.Join(", ", allValues)}), got ({String.Join(", ", limitGreaterThanSetSizeHitValues)})"); + + CacheSetSampleResponse limitZeroResponse = await client.SetSampleAsync(cacheName, setName, 0); + // TODO: for now the server is returning a MISS for this. We will are updating that behavior and will need to fix this + // test accordingly, but this is an edge case that we don't need to block the SDK release on so we can fix the test + // once the server behavior changes. + Assert.True(limitZeroResponse is CacheSetSampleResponse.Miss, $"Unexpected response: {limitZeroResponse}"); + // var limitZeroHitValues = ((CacheSetSampleResponse.Hit)limitZeroResponse).ValueSetString; + // Assert.True(allValues.SetEquals(limitZeroHitValues), $"Expected sample with with limit zero to return the entire set; expected ({allValues}), got ({limitZeroHitValues})"); + + for (int i = 0; i < 10; i++) + { + CacheSetSampleResponse response = await client.SetSampleAsync(cacheName, setName, allValues.Count - 2); + Assert.True(response is CacheSetSampleResponse.Hit, $"Unexpected response: {response}"); + var hitResponse = (CacheSetSampleResponse.Hit)response; + var hitValues = hitResponse.ValueSetString; + Assert.True(hitValues.IsSubsetOf(allValues), + $"Expected hit values ({String.Join(", ", hitValues)}) to be subset of all values ({String.Join(", ", allValues)}), but it is not!"); + } + } + [Fact] public async Task CacheSetFetchResponse_ToString_HappyPath() diff --git a/tests/Integration/Momento.Sdk.Tests/TestCacheClient.cs b/tests/Integration/Momento.Sdk.Tests/TestCacheClient.cs index 295647bb..19a40fd3 100644 --- a/tests/Integration/Momento.Sdk.Tests/TestCacheClient.cs +++ b/tests/Integration/Momento.Sdk.Tests/TestCacheClient.cs @@ -316,6 +316,11 @@ public Task SetFetchAsync(string cacheName, string setNam { return ((ICacheClient)client).SetFetchAsync(cacheName, setName); } + + public Task SetSampleAsync(string cacheName, string setName, int limit) + { + return ((ICacheClient)client).SetSampleAsync(cacheName, setName, limit); + } public Task SetIfNotExistsAsync(string cacheName, string key, string value, TimeSpan? ttl = null) {