diff --git a/Momento/MomentoSdk.csproj b/Momento/MomentoSdk.csproj index effa1ea0..e280b090 100644 --- a/Momento/MomentoSdk.csproj +++ b/Momento/MomentoSdk.csproj @@ -1,30 +1,30 @@ - - - - netstandard2.1 - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + netstandard2.1 + latest + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Momento/Responses/CacheMultiGetFailureResponse.cs b/Momento/Responses/CacheMultiGetFailureResponse.cs new file mode 100644 index 00000000..ea16bc07 --- /dev/null +++ b/Momento/Responses/CacheMultiGetFailureResponse.cs @@ -0,0 +1,16 @@ +using System; + +namespace MomentoSdk.Responses +{ + public class CacheMultiGetFailureResponse + { + public byte[] Key { get; private set; } + public Exception Failure { get; private set; } + + public CacheMultiGetFailureResponse(byte[] key, Exception failure) + { + Key = key; + Failure = failure; + } + } +} diff --git a/Momento/Responses/CacheMultiGetResponse.cs b/Momento/Responses/CacheMultiGetResponse.cs new file mode 100644 index 00000000..165a178f --- /dev/null +++ b/Momento/Responses/CacheMultiGetResponse.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; + +namespace MomentoSdk.Responses +{ + public class CacheMultiGetResponse + { + private readonly List successfulResponses; + private readonly List failedResponses; + + private readonly CacheGetResponse successfulResponse = null; + private readonly CacheMultiGetFailureResponse failedResponse = null; + + public CacheMultiGetResponse(List cacheGetResponses, List cacheMultiGetFailureResponses) + { + successfulResponses = cacheGetResponses; + failedResponses = cacheMultiGetFailureResponses; + } + + public CacheMultiGetResponse(CacheGetResponse cacheGetResponse) + { + successfulResponse = cacheGetResponse; + } + + public CacheMultiGetResponse(CacheMultiGetFailureResponse cacheMultiGetFailureResponse) + { + failedResponse = cacheMultiGetFailureResponse; + } + + public CacheGetResponse SuccessfulResponse() + { + return successfulResponse; + } + + public CacheMultiGetFailureResponse FailedResponse() + { + return failedResponse; + } + + public List SuccessfulResponses() + { + return successfulResponses; + } + + public List FailedResponses() + { + return failedResponses; + } + + public List Strings() + { + List values = new(); + foreach (CacheGetResponse response in successfulResponses) + { + values.Add(response.String()); + } + return values; + } + + public List Bytes() + { + List values = new(); + foreach (CacheGetResponse response in successfulResponses) + { + values.Add(response.Bytes()); + } + return values; + } + } +} diff --git a/Momento/ScsDataClient.cs b/Momento/ScsDataClient.cs index ded082ca..896191f1 100644 --- a/Momento/ScsDataClient.cs +++ b/Momento/ScsDataClient.cs @@ -5,6 +5,7 @@ using CacheClient; using Google.Protobuf; using Grpc.Core; +using System.Collections.Generic; namespace MomentoSdk { @@ -12,26 +13,26 @@ internal sealed class ScsDataClient : IDisposable { private readonly DataGrpcManager grpcManager; private readonly uint defaultTtlSeconds; - private readonly uint dataClientOperationTimeoutSeconds; - private const uint DEFAULT_DEADLINE_SECONDS = 5; + private readonly uint dataClientOperationTimeoutMilliseconds; + private const uint DEFAULT_DEADLINE_MILLISECONDS = 5000; public ScsDataClient(string authToken, string endpoint, uint defaultTtlSeconds) { this.grpcManager = new DataGrpcManager(authToken, endpoint); this.defaultTtlSeconds = defaultTtlSeconds; - this.dataClientOperationTimeoutSeconds = DEFAULT_DEADLINE_SECONDS; + this.dataClientOperationTimeoutMilliseconds = DEFAULT_DEADLINE_MILLISECONDS; } - public ScsDataClient(string authToken, string endpoint, uint defaultTtlSeconds, uint dataClientOperationTimeoutSeconds) + public ScsDataClient(string authToken, string endpoint, uint defaultTtlSeconds, uint dataClientOperationTimeoutMilliseconds) { this.grpcManager = new DataGrpcManager(authToken, endpoint); this.defaultTtlSeconds = defaultTtlSeconds; - this.dataClientOperationTimeoutSeconds = dataClientOperationTimeoutSeconds; + this.dataClientOperationTimeoutMilliseconds = dataClientOperationTimeoutMilliseconds; } public async Task SetAsync(string cacheName, byte[] key, byte[] value, uint ttlSeconds) { - _SetResponse response = await this.SendSetAsync(cacheName, value: Convert(value), key: Convert(key), ttlSeconds: ttlSeconds, dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _SetResponse response = await this.SendSetAsync(cacheName, value: Convert(value), key: Convert(key), ttlSeconds: ttlSeconds, dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheSetResponse(response); } @@ -42,13 +43,13 @@ public async Task SetAsync(string cacheName, byte[] key, byte[ public async Task GetAsync(string cacheName, byte[] key) { - _GetResponse resp = await this.SendGetAsync(cacheName, Convert(key), dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _GetResponse resp = await this.SendGetAsync(cacheName, Convert(key), dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheGetResponse(resp); } public async Task SetAsync(string cacheName, string key, string value, uint ttlSeconds) { - _SetResponse response = await this.SendSetAsync(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _SetResponse response = await this.SendSetAsync(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheSetResponse(response); } @@ -59,24 +60,69 @@ public async Task SetAsync(string cacheName, string key, strin public async Task GetAsync(string cacheName, string key) { - _GetResponse resp = await this.SendGetAsync(cacheName, Convert(key), dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _GetResponse resp = await this.SendGetAsync(cacheName, Convert(key), dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheGetResponse(resp); } public async Task SetAsync(string cacheName, string key, byte[] value, uint ttlSeconds) { - _SetResponse response = await this.SendSetAsync(cacheName, value: Convert(value), key: Convert(key), ttlSeconds: ttlSeconds, dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _SetResponse response = await this.SendSetAsync(cacheName, value: Convert(value), key: Convert(key), ttlSeconds: ttlSeconds, dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheSetResponse(response); } - public async Task SetAsync(string cacheName, string key, byte[] value) + public async Task SetAsync(string cacheName, string key, byte[] value) { return await this.SetAsync(cacheName, key, value, defaultTtlSeconds); } + public async Task MultiGetAsync(string cacheName, List keys) + { + List> tasks = new(); + List successResponses = new(); + List failedResponses = new(); + foreach (string key in keys) + { + tasks.Add(SendMultiGetAsync(cacheName, Convert(key))); + } + + await Task.WhenAll(tasks); + ProcessCacheMultiGetResponseTaskResult(tasks, successResponses, failedResponses); + return new CacheMultiGetResponse(successResponses, failedResponses); + } + + public async Task MultiGetAsync(string cacheName, List keys) + { + List> tasks = new(); + List successResponses = new(); + List failedResponses = new(); + foreach (byte[] key in keys) + { + tasks.Add(SendMultiGetAsync(cacheName, Convert(key))); + } + + await Task.WhenAll(tasks); + ProcessCacheMultiGetResponseTaskResult(tasks, successResponses, failedResponses); + return new CacheMultiGetResponse(successResponses, failedResponses); + } + + public async Task MultiGetAsync(string cacheName, List responses) + { + List> tasks = new(); + List successResponses = new(); + List failedResponses = new(); + foreach (CacheMultiGetFailureResponse response in responses) + { + tasks.Add(SendMultiGetAsync(cacheName, Convert(response.Key))); + } + + await Task.WhenAll(tasks); + ProcessCacheMultiGetResponseTaskResult(tasks, successResponses, failedResponses); + return new CacheMultiGetResponse(successResponses, failedResponses); + } + public CacheSetResponse Set(string cacheName, byte[] key, byte[] value, uint ttlSeconds) { - _SetResponse resp = this.SendSet(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _SetResponse resp = this.SendSet(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheSetResponse(resp); } @@ -87,13 +133,13 @@ public CacheSetResponse Set(string cacheName, byte[] key, byte[] value) public CacheGetResponse Get(string cacheName, byte[] key) { - _GetResponse resp = this.SendGet(cacheName, Convert(key), dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _GetResponse resp = this.SendGet(cacheName, Convert(key), dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheGetResponse(resp); } public CacheSetResponse Set(string cacheName, string key, string value, uint ttlSeconds) { - _SetResponse response = this.SendSet(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _SetResponse response = this.SendSet(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheSetResponse(response); } @@ -104,13 +150,13 @@ public CacheSetResponse Set(string cacheName, string key, string value) public CacheGetResponse Get(string cacheName, string key) { - _GetResponse resp = this.SendGet(cacheName, Convert(key), dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _GetResponse resp = this.SendGet(cacheName, Convert(key), dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheGetResponse(resp); } public CacheSetResponse Set(string cacheName, string key, byte[] value, uint ttlSeconds) { - _SetResponse response = this.SendSet(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutSeconds: this.dataClientOperationTimeoutSeconds); + _SetResponse response = this.SendSet(cacheName, key: Convert(key), value: Convert(value), ttlSeconds: ttlSeconds, dataClientOperationTimeoutMilliseconds: this.dataClientOperationTimeoutMilliseconds); return new CacheSetResponse(response); } @@ -119,10 +165,10 @@ public CacheSetResponse Set(string cacheName, string key, byte[] value) return this.Set(cacheName, key, value, defaultTtlSeconds); } - private async Task<_SetResponse> SendSetAsync(string cacheName, ByteString key, ByteString value, uint ttlSeconds, uint dataClientOperationTimeoutSeconds) + private async Task<_SetResponse> SendSetAsync(string cacheName, ByteString key, ByteString value, uint ttlSeconds, uint dataClientOperationTimeoutMilliseconds) { _SetRequest request = new _SetRequest() { CacheBody = value, CacheKey = key, TtlMilliseconds = ttlSeconds * 1000 }; - DateTime deadline = DateTime.UtcNow.AddSeconds(dataClientOperationTimeoutSeconds); + DateTime deadline = DateTime.UtcNow.AddMilliseconds(dataClientOperationTimeoutMilliseconds); try { return await this.grpcManager.Client().SetAsync(request, new Metadata { { "cache", cacheName } }, deadline: deadline); @@ -133,10 +179,10 @@ private async Task<_SetResponse> SendSetAsync(string cacheName, ByteString key, } } - private _GetResponse SendGet(string cacheName, ByteString key, uint dataClientOperationTimeoutSeconds) + private _GetResponse SendGet(string cacheName, ByteString key, uint dataClientOperationTimeoutMilliseconds) { _GetRequest request = new _GetRequest() { CacheKey = key }; - DateTime deadline = DateTime.UtcNow.AddSeconds(dataClientOperationTimeoutSeconds); + DateTime deadline = DateTime.UtcNow.AddMilliseconds(dataClientOperationTimeoutMilliseconds); try { return this.grpcManager.Client().Get(request, new Metadata { { "cache", cacheName } }, deadline: deadline); @@ -147,10 +193,10 @@ private _GetResponse SendGet(string cacheName, ByteString key, uint dataClientOp } } - private async Task<_GetResponse> SendGetAsync(string cacheName, ByteString key, uint dataClientOperationTimeoutSeconds) + private async Task<_GetResponse> SendGetAsync(string cacheName, ByteString key, uint dataClientOperationTimeoutMilliseconds) { _GetRequest request = new _GetRequest() { CacheKey = key }; - DateTime deadline = DateTime.UtcNow.AddSeconds(dataClientOperationTimeoutSeconds); + DateTime deadline = DateTime.UtcNow.AddMilliseconds(dataClientOperationTimeoutMilliseconds); try { return await this.grpcManager.Client().GetAsync(request, new Metadata { { "cache", cacheName } }, deadline: deadline); @@ -161,10 +207,10 @@ private async Task<_GetResponse> SendGetAsync(string cacheName, ByteString key, } } - private _SetResponse SendSet(string cacheName, ByteString key, ByteString value, uint ttlSeconds, uint dataClientOperationTimeoutSeconds) + private _SetResponse SendSet(string cacheName, ByteString key, ByteString value, uint ttlSeconds, uint dataClientOperationTimeoutMilliseconds) { _SetRequest request = new _SetRequest() { CacheBody = value, CacheKey = key, TtlMilliseconds = ttlSeconds * 1000 }; - DateTime deadline = DateTime.UtcNow.AddSeconds(dataClientOperationTimeoutSeconds); + DateTime deadline = DateTime.UtcNow.AddMilliseconds(dataClientOperationTimeoutMilliseconds); try { return this.grpcManager.Client().Set(request, new Metadata { { "cache", cacheName } }, deadline: deadline); @@ -175,6 +221,38 @@ private _SetResponse SendSet(string cacheName, ByteString key, ByteString value, } } + private async Task SendMultiGetAsync(string cacheName, ByteString key) + { + _GetRequest request = new _GetRequest() { CacheKey = key }; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(this.dataClientOperationTimeoutMilliseconds); + try + { + _GetResponse resp = await this.grpcManager.Client().GetAsync(request, new Metadata { { "cache", cacheName } }, deadline: deadline); + return new CacheMultiGetResponse(new CacheGetResponse(resp)); + + } + catch (Exception e) + { + return new CacheMultiGetResponse(new CacheMultiGetFailureResponse(key.ToByteArray(), CacheExceptionMapper.Convert(e))); + } + + } + + private void ProcessCacheMultiGetResponseTaskResult(List> tasks, List successResponses, List failedResponses) + { + foreach (Task t in tasks) + { + if (t.Result.SuccessfulResponse() is not null) + { + successResponses.Add(t.Result.SuccessfulResponse()); + } + if (t.Result.FailedResponse() is not null) + { + failedResponses.Add(t.Result.FailedResponse()); + } + } + } + private ByteString Convert(byte[] bytes) { return ByteString.CopyFrom(bytes); diff --git a/Momento/SimpleCacheClient.cs b/Momento/SimpleCacheClient.cs index c784ce16..4e54f053 100644 --- a/Momento/SimpleCacheClient.cs +++ b/Momento/SimpleCacheClient.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using MomentoSdk.Exceptions; +using System.Collections.Generic; namespace MomentoSdk.Responses { @@ -25,14 +26,14 @@ public SimpleCacheClient(string authToken, uint defaultTtlSeconds) this.dataClient = dataClient; } - public SimpleCacheClient(string authToken, uint defaultTtlSeconds, uint dataClientOperationTimeoutSeconds) + public SimpleCacheClient(string authToken, uint defaultTtlSeconds, uint dataClientOperationTimeoutMilliseconds) { - ValidateRequestTimeout(dataClientOperationTimeoutSeconds); + ValidateRequestTimeout(dataClientOperationTimeoutMilliseconds); Claims claims = JwtUtils.DecodeJwt(authToken); string controlEndpoint = "https://" + claims.ControlEndpoint + ":443"; string cacheEndpoint = "https://" + claims.CacheEndpoint + ":443"; ScsControlClient controlClient = new ScsControlClient(authToken, controlEndpoint); - ScsDataClient dataClient = new ScsDataClient(authToken, cacheEndpoint, defaultTtlSeconds, dataClientOperationTimeoutSeconds); + ScsDataClient dataClient = new ScsDataClient(authToken, cacheEndpoint, defaultTtlSeconds, dataClientOperationTimeoutMilliseconds); this.controlClient = controlClient; this.dataClient = dataClient; } @@ -155,6 +156,36 @@ public async Task SetAsync(string cacheName, string key, byte[ return await this.dataClient.SetAsync(cacheName, key, value); } + /// + /// Executes a list of passed Get operations in parallel. + /// + /// The keys to perform a cache lookup on + /// Future with CacheMultiGetResponse containing the status of the get operation and the associated value data + public async Task MultiGetAsync(string cacheName, List keys) + { + return await this.dataClient.MultiGetAsync(cacheName, keys); + } + + /// + /// Executes a list of passed Get operations in parallel. + /// + /// The keys to perform a cache lookup on + /// Future with CacheMultiGetResponse containing the status of the get operation and the associated value data + public async Task MultiGetAsync(string cacheName, List keys) + { + return await this.dataClient.MultiGetAsync(cacheName, keys); + } + + /// + /// Executes a list of passed Get operations in parallel. + /// + /// Failed responses to perform a cache lookup on + /// Future with CacheMultiGetResponse containing the status of the get operation and the associated value data + public async Task MultiGetAsync(string cacheName, List failureResponses) + { + return await this.dataClient.MultiGetAsync(cacheName, failureResponses); + } + /// /// Sets the value in the cache. If a value for this key is already present it will be replaced by the new value. /// @@ -162,7 +193,7 @@ public async Task SetAsync(string cacheName, string key, byte[ /// The value to be stored /// Time to Live for the item in Cache. This ttl takes precedence over the TTL used when initializing a cache client /// Result of the set operation - + public CacheSetResponse Set(string cacheName, byte[] key, byte[] value, uint ttlSeconds) { return this.dataClient.Set(cacheName, key, value, ttlSeconds); @@ -255,9 +286,9 @@ public void Dispose() GC.SuppressFinalize(this); } - private void ValidateRequestTimeout(uint requestTimeoutSeconds) + private void ValidateRequestTimeout(uint requestTimeoutMilliseconds) { - if (requestTimeoutSeconds == 0) + if (requestTimeoutMilliseconds == 0) { throw new InvalidArgumentException("Request timeout must be greater than zero."); } diff --git a/MomentoIntegrationTest/CacheTest.cs b/MomentoIntegrationTest/CacheTest.cs index a46aa5c4..e16a1fd1 100644 --- a/MomentoIntegrationTest/CacheTest.cs +++ b/MomentoIntegrationTest/CacheTest.cs @@ -4,6 +4,7 @@ using MomentoSdk.Responses; using MomentoSdk.Exceptions; using System.Text; +using System.Collections.Generic; namespace MomentoIntegrationTest { @@ -19,9 +20,12 @@ public CacheTest() { uint defaultTtlSeconds = 10; client = new SimpleCacheClient(authKey, defaultTtlSeconds); - try { + try + { client.CreateCache(cacheName); - } catch (AlreadyExistsException) { + } + catch (AlreadyExistsException) + { } } @@ -43,6 +47,65 @@ public void HappyPath() Assert.Equal(cacheValue, stringResult); } + [Fact] + public async Task HappyPathMultiGetAsync() + { + string cacheKey1 = "key1"; + string cacheValue1 = "value1"; + string cacheKey2 = "key2"; + string cacheValue2 = "value2"; + client.Set(cacheName, cacheKey1, cacheValue1, defaultTtlSeconds); + client.Set(cacheName, cacheKey2, cacheValue2, defaultTtlSeconds); + List keys = new() { cacheKey1, cacheKey2 }; + CacheMultiGetResponse result = await client.MultiGetAsync(cacheName, keys); + string stringResult1 = result.Strings()[0]; + string stringResult2 = result.Strings()[1]; + Assert.Equal(cacheValue1, stringResult1); + Assert.Equal(cacheValue2, stringResult2); + } + + [Fact] + public async Task HappyPathMultiGetAsyncByteKeys() + { + string cacheKey1 = "key1"; + string cacheValue1 = "value1"; + string cacheKey2 = "key2"; + string cacheValue2 = "value2"; + client.Set(cacheName, cacheKey1, cacheValue1, defaultTtlSeconds); + client.Set(cacheName, cacheKey2, cacheValue2, defaultTtlSeconds); + List keys = new() { Encoding.ASCII.GetBytes(cacheKey1), Encoding.ASCII.GetBytes(cacheKey2) }; + CacheMultiGetResponse result = await client.MultiGetAsync(cacheName, keys); + string stringResult1 = result.Strings()[0]; + string stringResult2 = result.Strings()[1]; + Assert.Equal(cacheValue1, stringResult1); + Assert.Equal(cacheValue2, stringResult2); + } + + [Fact] + public async Task HappyPathMultiGetAsyncFailureRetry() + { + // Set very small timeout for dataClientOperationTimeoutMilliseconds + SimpleCacheClient simpleCacheClient = new SimpleCacheClient(authKey, defaultTtlSeconds, 1); + string cacheKey1 = "key1"; + string cacheValue1 = "value1"; + string cacheKey2 = "key2"; + string cacheValue2 = "value2"; + List keys = new() { cacheKey1, cacheKey2 }; + CacheMultiGetResponse failedResult = await simpleCacheClient.MultiGetAsync(cacheName, keys); + Assert.Equal(2, failedResult.FailedResponses().Count); + Assert.Empty(failedResult.SuccessfulResponses()); + + // Use normal test client and retry + client.Set(cacheName, cacheKey1, cacheValue1, defaultTtlSeconds); + client.Set(cacheName, cacheKey2, cacheValue2, defaultTtlSeconds); + CacheMultiGetResponse result = await client.MultiGetAsync(cacheName, failedResult.FailedResponses()); + string stringResult1 = result.Strings()[0]; + string stringResult2 = result.Strings()[1]; + Assert.Equal(2, result.SuccessfulResponses().Count); + Assert.Equal(cacheValue1, stringResult1); + Assert.Equal(cacheValue2, stringResult2); + } + [Fact] public void HappyPathStringKeyByteValue() { diff --git a/MomentoIntegrationTest/SimpleCacheControlPlaneTests.cs b/MomentoIntegrationTest/SimpleCacheControlPlaneTests.cs index 3509cd13..c59b4c12 100644 --- a/MomentoIntegrationTest/SimpleCacheControlPlaneTests.cs +++ b/MomentoIntegrationTest/SimpleCacheControlPlaneTests.cs @@ -3,7 +3,6 @@ using MomentoSdk.Exceptions; using MomentoSdk.Responses; using System.Collections.Generic; -using System.Threading.Tasks; namespace MomentoIntegrationTest { @@ -44,8 +43,8 @@ public void HappyPathListCache() public void InvalidRequestTimeout() { uint defaultTtlSeconds = 10; - uint requestTimeoutSeconds = 0; - Assert.Throws(() => new SimpleCacheClient(authKey, defaultTtlSeconds, requestTimeoutSeconds)); + uint requestTimeoutMilliseconds = 0; + Assert.Throws(() => new SimpleCacheClient(authKey, defaultTtlSeconds, requestTimeoutMilliseconds)); } } }