[#19] Client: Use specific classes for search
All checks were successful
DCO / DCO (pull_request) Successful in 27s

Signed-off-by: Pavel Gross <p.gross@yando.com>
This commit is contained in:
Pavel Gross 2024-07-25 14:20:14 +03:00
parent 3206abc33e
commit 35fe791406
27 changed files with 320 additions and 123 deletions

View file

@ -7,7 +7,7 @@ namespace FrostFS.SDK.ClientV2.Mappers.GRPC;
public static class ObjectFilterMapper
{
public static SearchRequest.Types.Body.Types.Filter ToGrpcMessage(this ObjectFilter filter)
public static SearchRequest.Types.Body.Types.Filter ToGrpcMessage(this IObjectFilter filter)
{
var objMatchTypeName = filter.MatchType switch
{
@ -24,7 +24,7 @@ public static class ObjectFilterMapper
{
MatchType = objMatchTypeName,
Key = filter.Key,
Value = filter.Value
Value = filter.GetSerializedValue()
};
}
}

View file

@ -74,7 +74,7 @@ public static class ObjectHeaderMapper
if (header.Split != null)
{
model.Split = new Split(SplitId.CreateFromBinary(header.Split.SplitId.ToByteArray()))
model.Split = new Split(new SplitId(header.Split.SplitId.ToUuid()))
{
Parent = header.Split.Parent?.ToModel(),
ParentHeader = header.Split.ParentHeader?.ToModel(),

View file

@ -4,8 +4,6 @@ namespace FrostFS.SDK.ClientV2.Parameters;
public sealed class PrmContainerGetAll() : IContext
{
public string SessionToken { get; set; } = string.Empty;
/// <summary>
/// FrostFS request X-Headers
/// </summary>

View file

@ -3,15 +3,19 @@ using System.Collections.Specialized;
using FrostFS.SDK.ModelsV2;
namespace FrostFS.SDK.ClientV2.Parameters;
public sealed class PrmObjectSearch(ContainerId containerId, params ObjectFilter[] filters) : IContext, ISessionToken
public sealed class PrmObjectSearch(ContainerId containerId, params IObjectFilter[] filters) : IContext, ISessionToken
{
/// <summary>
/// Defines container for the search
/// </summary>
/// <value></value>
public ContainerId ContainerId { get; set; } = containerId;
/// <summary>
/// Defines the search criteria
/// </summary>
/// <value>Collection of filters</value>
public IEnumerable<ObjectFilter> Filters { get; set; } = filters;
public IEnumerable<IObjectFilter> Filters { get; set; } = filters;
/// <summary>
/// FrostFS request X-Headers

View file

@ -1,33 +1,25 @@
using System.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Threading.Tasks;
using FrostFS.SDK.ClientV2.Mappers.GRPC.Netmap;
using FrostFS.Container;
using FrostFS.SDK.ClientV2.Mappers.GRPC;
using FrostFS.SDK.Cryptography;
using System.Collections.Generic;
using FrostFS.SDK.ModelsV2;
using FrostFS.Refs;
using System;
using FrostFS.SDK.ClientV2.Parameters;
using System.Collections.Specialized;
using FrostFS.Refs;
namespace FrostFS.SDK.ClientV2;
internal class ContainerServiceProvider : ContextAccessor
internal class ContainerServiceProvider(ContainerService.ContainerServiceClient service, ClientEnvironment context) : ContextAccessor(context)
{
private readonly ContainerService.ContainerServiceClient containerServiceClient;
internal ContainerServiceProvider(ContainerService.ContainerServiceClient service, ClientEnvironment context)
: base(context)
{
containerServiceClient = service;
}
internal async Task<ModelsV2.Container> GetContainerAsync(PrmContainerGet args)
{
GetRequest request = GetContainerRequest(args.ContainerId.ToGrpcMessage(), args.XHeaders);
var response = await containerServiceClient.GetAsync(request, null, args.Context!.Deadline, args.Context.CancellationToken);
var response = await service.GetAsync(request, null, args.Context!.Deadline, args.Context.CancellationToken);
Verifier.CheckResponse(response);
@ -49,7 +41,7 @@ internal class ContainerServiceProvider : ContextAccessor
request.AddMetaHeader(args.XHeaders);
request.Sign(Context.Key);
var response = await containerServiceClient.ListAsync(request, null, ctx.Deadline, ctx.CancellationToken);
var response = await service.ListAsync(request, null, ctx.Deadline, ctx.CancellationToken);
Verifier.CheckResponse(response);
@ -78,7 +70,7 @@ internal class ContainerServiceProvider : ContextAccessor
request.AddMetaHeader(args.XHeaders);
request.Sign(Context.Key);
var response = await containerServiceClient.PutAsync(request, null, ctx.Deadline, ctx.CancellationToken);
var response = await service.PutAsync(request, null, ctx.Deadline, ctx.CancellationToken);
Verifier.CheckResponse(response);
@ -100,9 +92,10 @@ internal class ContainerServiceProvider : ContextAccessor
};
request.AddMetaHeader(args.XHeaders);
request.Sign(Context.Key);
var response = await containerServiceClient.DeleteAsync(request, null, ctx.Deadline, ctx.CancellationToken);
var response = await service.DeleteAsync(request, null, ctx.Deadline, ctx.CancellationToken);
await WaitForContainer(WaitExpects.Removed, request.Body.ContainerId, args.WaitParams, ctx);
@ -137,7 +130,7 @@ internal class ContainerServiceProvider : ContextAccessor
async Task action()
{
var response = await containerServiceClient.GetAsync(request, null, ctx.Deadline, ctx.CancellationToken);
var response = await service.GetAsync(request, null, ctx.Deadline, ctx.CancellationToken);
Verifier.CheckResponse(response);
}
@ -161,6 +154,9 @@ internal class ContainerServiceProvider : ContextAccessor
if (expect == WaitExpects.Exists)
return;
if (DateTime.UtcNow >= deadLine)
throw new TimeoutException();
await Task.Delay(waitParams.PollInterval);
}
catch (ResponseException ex)
@ -179,5 +175,3 @@ internal class ContainerServiceProvider : ContextAccessor
}
}
}

View file

@ -12,14 +12,19 @@ using FrostFS.SDK.Cryptography;
using FrostFS.Session;
using FrostFS.SDK.ModelsV2;
using FrostFS.SDK.ClientV2.Extensions;
using System.Threading;
using FrostFS.SDK.ClientV2.Parameters;
namespace FrostFS.SDK.ClientV2;
internal class ObjectServiceProvider(ObjectService.ObjectServiceClient client, ClientEnvironment ctx) : ContextAccessor(ctx)
internal class ObjectServiceProvider(ObjectService.ObjectServiceClient client, ClientEnvironment ctx) : ContextAccessor(ctx), ISessionProvider
{
readonly ObjectTools tools = new(ctx);
readonly SessionProvider sessions = new (ctx);
public async ValueTask<Session.SessionToken> GetOrCreateSession(ISessionToken args, Context ctx)
{
return await sessions.GetOrCreateSession(args, ctx);
}
internal async Task<ObjectHeader> GetObjectHeadAsync(PrmObjectHeadGet args)
{
@ -193,8 +198,6 @@ internal class ObjectServiceProvider(ObjectService.ObjectServiceClient client, C
return ObjectId.FromHash(grpcObject.ObjectId.Value.ToByteArray());
}
static readonly AsyncLocal<Session.SessionToken> asyncLocalSession = new ();
private async Task<ObjectId> PutClientCutObject(PrmObjectPut args)
{
var ctx = args.Context!;
@ -274,7 +277,9 @@ internal class ObjectServiceProvider(ObjectService.ObjectServiceClient client, C
return tools.CalculateObjectId(largeObject.Header);
}
currentObject.AddAttributes(args.Header!.Attributes);
currentObject
.SetSplit(null)
.AddAttributes(args.Header!.Attributes);
return await PutSingleObjectAsync(new PrmSingleObjectPut(currentObject, ctx));
}
@ -421,14 +426,4 @@ internal class ObjectServiceProvider(ObjectService.ObjectServiceClient client, C
return new SearchReader(call);
}
private async ValueTask<Session.SessionToken> GetOrCreateSession(ISessionToken args, Context ctx)
{
if (args.SessionToken is null)
{
return await Context.Client.CreateSessionInternalAsync(new PrmSessionCreate(uint.MaxValue, ctx));
}
return new Session.SessionToken().Deserialize(args.SessionToken.Token);
}
}

View file

@ -0,0 +1,23 @@
using System.Threading.Tasks;
using FrostFS.SDK.ClientV2.Parameters;
namespace FrostFS.SDK.ClientV2;
internal interface ISessionProvider
{
ValueTask<Session.SessionToken> GetOrCreateSession(ISessionToken args, Context ctx);
}
internal class SessionProvider(ClientEnvironment env)
{
public async ValueTask<Session.SessionToken> GetOrCreateSession(ISessionToken args, Context ctx)
{
if (args.SessionToken is null)
{
return await env.Client.CreateSessionInternalAsync(new PrmSessionCreate(uint.MaxValue, ctx));
}
return new Session.SessionToken().Deserialize(args.SessionToken.Token);
}
}

View file

@ -37,7 +37,7 @@ public static class ObjectExtensions
return obj;
}
public static FrostFsObject SetSplit(this FrostFsObject obj, Split split)
public static FrostFsObject SetSplit(this FrostFsObject obj, Split? split)
{
obj.Header.Split = split;
return obj;

View file

@ -80,9 +80,7 @@ internal class ObjectTools(ClientEnvironment ctx) : ContextAccessor (ctx)
grpcHeader.OwnerId = Context.Owner.ToGrpcMessage();
grpcHeader.Version = Context.Version.ToGrpcMessage();
if (header.PayloadCheckSum != null)
grpcHeader.PayloadHash = Sha256Checksum(header.PayloadCheckSum);
else if (payload != null)
if (payload != null)
grpcHeader.PayloadHash = Sha256Checksum(payload);
return grpcHeader;

View file

@ -7,18 +7,49 @@ public static class UUIDExtension
{
public static Guid ToUuid(this ByteString id)
{
return Guid.Parse(BitConverter.ToString(id.ToByteArray()).Replace("-", ""));
var bytes = id.ToByteArray();
var orderedBytes = GetGuidBytesDirectOrder(bytes);
return new Guid(orderedBytes);
}
/// <summary>
/// Serializes Guid to binary representation in direct order bytes format
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public static byte[] ToBytes(this Guid id)
{
var str = id.ToString("N");
var len = str.Length;
var bytes = new byte[len/2];
var bytes = id.ToByteArray();
for (int i = 0; i < len; i += 2)
bytes[i/2] = Convert.ToByte(str.Substring(i, 2), 16);
var orderedBytes = GetGuidBytesDirectOrder(bytes);
return bytes;
return orderedBytes;
}
private static byte[] GetGuidBytesDirectOrder(byte[] source)
{
if (source.Length != 16)
throw new ArgumentException("Wrong uuid binary format");
return [
source[3],
source[2],
source[1],
source[0],
source[5],
source[4],
source[7],
source[6],
source[8],
source[9],
source[10],
source[11],
source[12],
source[13],
source[14],
source[15]
];
}
}

View file

@ -1,10 +1,11 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace FrostFS.SDK.ModelsV2;
public class CheckSum
{
// type is always Sha256
public byte[]? Hash { get; set; }
public static byte[] GetHash(byte[] content)
@ -20,6 +21,6 @@ public class CheckSum
public override string ToString()
{
return Encoding.UTF8.GetString(Hash);
return BitConverter.ToString(Hash).Replace("-", "");
}
}

View file

@ -69,19 +69,6 @@ public class LargeObject(ContainerId container) : FrostFsObject(container)
{
private readonly SHA256 payloadHash = SHA256.Create();
public void AppendBlock(byte[] bytes, int count)
{
Header!.PayloadLength += (ulong)count;
this.payloadHash.TransformBlock(bytes, 0, count, bytes, 0);
}
public LargeObject CalculateHash()
{
this.payloadHash.TransformFinalBlock([], 0, 0);
Header!.PayloadCheckSum = this.payloadHash.Hash;
return this;
}
public ulong PayloadLength
{
get { return Header!.PayloadLength; }

View file

@ -2,37 +2,112 @@ using FrostFS.SDK.ModelsV2.Enums;
namespace FrostFS.SDK.ModelsV2;
public class ObjectFilter
public interface IObjectFilter
{
private const string HeaderPrefix = "$Object:";
public ObjectMatchType MatchType { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public ObjectFilter(ObjectMatchType matchType, string key, string value)
{
MatchType = matchType;
Key = key;
Value = value;
}
string? GetSerializedValue();
}
public static ObjectFilter ObjectIdFilter(ObjectMatchType matchType, ObjectId objectId)
{
return new ObjectFilter(matchType, HeaderPrefix + "objectID", objectId.Value);
}
public abstract class ObjectFilter<T>(ObjectMatchType matchType, string key, T value) : IObjectFilter
{
public ObjectMatchType MatchType { get; set; } = matchType;
public string Key { get; set; } = key;
public static ObjectFilter OwnerFilter(ObjectMatchType matchType, OwnerId ownerId)
{
return new ObjectFilter(matchType, HeaderPrefix + "ownerID", ownerId.Value);
}
public T Value { get; set; } = value;
public static ObjectFilter RootFilter()
public string? GetSerializedValue()
{
return new ObjectFilter(ObjectMatchType.Unspecified, HeaderPrefix + "ROOT", "");
}
public static ObjectFilter VersionFilter(ObjectMatchType matchType, Version version)
{
return new ObjectFilter(matchType, HeaderPrefix + "version", version.ToString());
return Value?.ToString();
}
}
/// <summary>
/// Creates filter to search by Attribute
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="key">Attribute key</param>
/// <param name="value">Attribute value</param>
public class FilterByAttribute(ObjectMatchType matchType, string key, string value) : ObjectFilter<string>(matchType, key, value) { }
/// <summary>
/// Creates filter to search by ObjectId
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="objectId">ObjectId</param>
public class FilterByObjectId(ObjectMatchType matchType, ObjectId objectId) : ObjectFilter<ObjectId>(matchType, Constants.FilterHeaderObjectID, objectId) { }
/// <summary>
/// Creates filter to search by OwnerId
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="ownerId">ObjectId</param>
public class FilterByOwnerId(ObjectMatchType matchType, OwnerId ownerId) : ObjectFilter<OwnerId>(matchType, Constants.FilterHeaderOwnerID, ownerId) {}
/// <summary>
/// Creates filter to search by Version
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="version">Version</param>
public class FilterByVersion(ObjectMatchType matchType, Version version) : ObjectFilter<Version>(matchType, Constants.FilterHeaderVersion, version) {}
/// <summary>
/// Creates filter to search by ContainerId
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="containerId">ContainerId</param>
public class FilterByContainerId(ObjectMatchType matchType, ContainerId containerId) : ObjectFilter<ContainerId>(matchType, Constants.FilterHeaderContainerID, containerId) {}
/// <summary>
/// Creates filter to search by creation Epoch
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="epoch">Creation Epoch</param>
public class FilterByEpoch(ObjectMatchType matchType, ulong epoch) : ObjectFilter<ulong>(matchType, Constants.FilterHeaderCreationEpoch, epoch) {}
/// <summary>
/// Creates filter to search by Payload Length
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="payloadLength">Payload Length</param>
public class FilterByPayloadLength(ObjectMatchType matchType, ulong payloadLength) : ObjectFilter<ulong>(matchType, Constants.FilterHeaderPayloadLength, payloadLength) {}
/// <summary>
/// Creates filter to search by Payload Hash
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="payloadHash">Payload Hash</param>
public class FilterByPayloadHash(ObjectMatchType matchType, CheckSum payloadHash) : ObjectFilter<CheckSum>(matchType, Constants.FilterHeaderPayloadHash, payloadHash) {}
/// <summary>
/// Creates filter to search by Parent
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="parentId">Parent</param>
public class FilterByParent(ObjectMatchType matchType, ObjectId parentId) : ObjectFilter<ObjectId>(matchType, Constants.FilterHeaderParent, parentId) {}
/// <summary>
/// Creates filter to search by SplitId
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="splitId">SplitId</param>
public class FilterBySplitId(ObjectMatchType matchType, SplitId splitId) : ObjectFilter<SplitId>(matchType, Constants.FilterHeaderSplitID, splitId) {}
/// <summary>
/// Creates filter to search by Payload Hash
/// </summary>
/// <param name="matchType">Match type</param>
/// <param name="ecParentId">Payload Hash</param>
public class FilterByECParent(ObjectMatchType matchType, ObjectId ecParentId) : ObjectFilter<ObjectId>(matchType, Constants.FilterHeaderECParent, ecParentId) {}
/// <summary>
/// Creates filter to search Root objects
/// </summary>
public class FilterByRootObject() : ObjectFilter<string>(ObjectMatchType.Unspecified, Constants.FilterHeaderRoot, string.Empty) {}
/// <summary>
/// Creates filter to search objects that are physically stored on the server
/// </summary
public class FilterByPhysicallyStored() : ObjectFilter<string>(ObjectMatchType.Unspecified, Constants.FilterHeaderPhy, string.Empty) {}

View file

@ -17,4 +17,9 @@ public class OwnerId(string id)
{
return Base58.Decode(Value);
}
public override string ToString()
{
return Value;
}
}

View file

@ -1,4 +1,5 @@
using System;
using FrostFS.SDK.Cryptography;
using System;
namespace FrostFS.SDK.ModelsV2;
@ -10,6 +11,7 @@ public class SplitId
{
this.id = Guid.NewGuid();
}
public SplitId(Guid guid)
{
this.id = guid;
@ -45,6 +47,6 @@ public class SplitId
if (this.id == Guid.Empty)
return null;
return this.id.ToByteArray();
return this.id.ToBytes();
}
}

View file

@ -1 +0,0 @@
namespace FrostFS.SDK.ModelsV2;

View file

@ -115,7 +115,7 @@ public class SmokeTests
{
Header = new ObjectHeader(
containerId: containerId,
type: ObjectType.Regular,
type: ModelsV2.Enums.ObjectType.Regular,
new ObjectAttribute("fileName", "test")),
Payload = new MemoryStream(bytes),
ClientCut = false,
@ -140,6 +140,88 @@ public class SmokeTests
await Cleanup(client);
}
[Fact]
public async void FilterTest()
{
using var client = Client.GetInstance(GetOptions(this.key, this.url));
await Cleanup(client);
var createContainerParam = new PrmContainerCreate(
new ModelsV2.Container(BasicAcl.PublicRW, new PlacementPolicy(true, new Replica(1))))
{
WaitParams = lightWait
};
var containerId = await client.CreateContainerAsync(createContainerParam);
var bytes = new byte[] { 1, 2, 3 };
var ParentHeader = new ObjectHeader(
containerId: containerId,
type: ModelsV2.Enums.ObjectType.Regular)
{
PayloadLength = 3
};
var param = new PrmObjectPut
{
Header = new ObjectHeader(
containerId: containerId,
type: ModelsV2.Enums.ObjectType.Regular,
new ObjectAttribute("fileName", "test"))
{
Split = new Split(),
},
Payload = new MemoryStream(bytes),
ClientCut = false
};
var objectId = await client.PutObjectAsync(param);
var head = await client.GetObjectHeadAsync(new PrmObjectHeadGet(containerId, objectId));
var ecdsaKey = this.key.LoadWif();
var networkInfo = await client.GetNetmapSnapshotAsync();
await CheckFilter(client, containerId, new FilterByContainerId(ObjectMatchType.Equals, containerId));
await CheckFilter(client, containerId, new FilterByOwnerId(ObjectMatchType.Equals, OwnerId.FromKey(ecdsaKey)));
await CheckFilter(client, containerId, new FilterBySplitId(ObjectMatchType.Equals, param.Header.Split.SplitId));
await CheckFilter(client, containerId, new FilterByAttribute(ObjectMatchType.Equals, "fileName", "test"));
await CheckFilter(client, containerId, new FilterByObjectId(ObjectMatchType.Equals, objectId));
await CheckFilter(client, containerId, new FilterByVersion(ObjectMatchType.Equals, networkInfo.NodeInfoCollection[0].Version));
await CheckFilter(client, containerId, new FilterByEpoch(ObjectMatchType.Equals, networkInfo.Epoch));
await CheckFilter(client, containerId, new FilterByPayloadLength(ObjectMatchType.Equals, 3));
var checkSum = CheckSum.CreateCheckSum(bytes);
await CheckFilter(client, containerId, new FilterByPayloadHash(ObjectMatchType.Equals, checkSum));
await CheckFilter(client, containerId, new FilterByPhysicallyStored());
}
private static async Task CheckFilter(IFrostFSClient client, ContainerId containerId, IObjectFilter filter)
{
var resultObjectsCount = 0;
PrmObjectSearch searchParam = new(containerId) { Filters = [filter] };
await foreach (var objId in client.SearchObjectsAsync(searchParam))
{
resultObjectsCount++;
}
Assert.True(1 == resultObjectsCount, $"Filter for {filter.Key} doesn't work");
}
[Theory]
[InlineData(1)]
[InlineData(3 * 1024 * 1024)] // exactly one chunk size - 3MB
@ -153,7 +235,7 @@ public class SmokeTests
bool callbackInvoked = false;
var ctx = new Context
{
Timeout = TimeSpan.FromSeconds(20),
// Timeout = TimeSpan.FromSeconds(20),
Callback = new((CallStatistics cs) =>
{
callbackInvoked = true;
@ -179,7 +261,7 @@ public class SmokeTests
{
Header = new ObjectHeader(
containerId: containerId,
type: ObjectType.Regular,
type: ModelsV2.Enums.ObjectType.Regular,
new ObjectAttribute("fileName", "test")),
Payload = new MemoryStream(bytes),
ClientCut = false,
@ -191,7 +273,7 @@ public class SmokeTests
var objectId = await client.PutObjectAsync(param);
var filter = new ObjectFilter(ObjectMatchType.Equals, "fileName", "test");
var filter = new FilterByAttribute(ObjectMatchType.Equals, "fileName", "test");
bool hasObject = false;
await foreach (var objId in client.SearchObjectsAsync(new PrmObjectSearch(containerId) { Filters = [filter] }))
@ -263,7 +345,7 @@ public class SmokeTests
{
Header = new ObjectHeader(
containerId: containerId,
type: ObjectType.Regular,
type: ModelsV2.Enums.ObjectType.Regular,
new ObjectAttribute("fileName", "test")),
Payload = new MemoryStream(bytes),
ClientCut = false,
@ -276,7 +358,7 @@ public class SmokeTests
var objectId = await client.PutObjectAsync(param);
var filter = new ObjectFilter(ObjectMatchType.Equals, "fileName", "test");
var filter = new FilterByAttribute(ObjectMatchType.Equals, "fileName", "test");
bool hasObject = false;
await foreach (var objId in client.SearchObjectsAsync(new PrmObjectSearch(containerId) { Filters = [filter], SessionToken = token }))
@ -319,6 +401,7 @@ public class SmokeTests
[InlineData(64 * 1024 * 1024 - 1)]
[InlineData(64 * 1024 * 1024 + 1)]
[InlineData(2 * 64 * 1024 * 1024 + 256)]
[InlineData(200)]
public async void ClientCutScenarioTest(int objectSize)
{
using var client = Client.GetInstance(GetOptions(this.key, this.url));
@ -348,7 +431,7 @@ public class SmokeTests
{
Header = new ObjectHeader(
containerId: containerId,
type: ObjectType.Regular,
type: ModelsV2.Enums.ObjectType.Regular,
new ObjectAttribute("fileName", "test")),
Payload = new MemoryStream(bytes),
ClientCut = true
@ -356,7 +439,7 @@ public class SmokeTests
var objectId = await client.PutObjectAsync(param);
var filter = new ObjectFilter(ObjectMatchType.Equals, "fileName", "test");
var filter = new FilterByAttribute(ObjectMatchType.Equals, "fileName", "test");
bool hasObject = false;
await foreach (var objId in client.SearchObjectsAsync(new PrmObjectSearch(containerId, filter)))
@ -385,6 +468,8 @@ public class SmokeTests
Assert.Equal(MD5.HashData(bytes), MD5.HashData(downloadedBytes));
await CheckFilter(client, containerId, new FilterByRootObject());
await Cleanup(client);
var deadline = DateTime.UtcNow.Add(TimeSpan.FromSeconds(5));