From 8a1214858e38bed86579182e1e169ec06519029a Mon Sep 17 00:00:00 2001 From: maumar Date: Wed, 1 May 2024 02:53:03 -0700 Subject: [PATCH 01/15] - Adding support for serverless indexes - Moving to new global API - Adding tests - Minor API fixes to more accurately reflect the API spec Fixes #91 Fixes #107 Lot of breaking changes here - there are significant differences between old and new APIs, also pod vs serverless Tests are mainly using serverless, since at the time of writing free pod based offering is broken (indexes won't initialize) NOT TESTED / OUTSTANDING ISSUES: collection operations - serverless doesn't support collections, pod based gcp-starter is currently broken (does it support collections) - create collection from index - restore index from collection - list collections (when there is something in them) metadata filtering on non-query operations - upsert, set metadata - delete vectors based on filter complex metadata operations (see https://docs.pinecone.io/guides/data/filtering-with-metadata#metadata-query-language) sparse vectors use legacy CreateIndex - environment is no longer part of PineconeClient definition, for now storing the value if legacy ctor was called. Also, where to get pods and pod_type? --- Pinecone.sln | 6 + example/Example.CSharp/Program.cs | 9 +- example/Example.FSharp/Program.fs | 11 +- src/Grpc/GrpcTransport.cs | 10 +- src/Index.cs | 8 +- src/PineconeClient.cs | 81 ++++-- src/Rest/SerializerContext.cs | 2 + src/Rest/Types.cs | 22 +- src/Types/CollectionTypes.cs | 19 +- src/Types/IndexTypes.cs | 48 +++- test/DataTests.cs | 419 ++++++++++++++++++++++++++++++ test/IndexTests.cs | 103 ++++++++ test/PineconeTests.csproj | 29 +++ test/UserSecretsExtensions.cs | 14 + 14 files changed, 716 insertions(+), 65 deletions(-) create mode 100644 test/DataTests.cs create mode 100644 test/IndexTests.cs create mode 100644 test/PineconeTests.csproj create mode 100644 test/UserSecretsExtensions.cs diff --git a/Pinecone.sln b/Pinecone.sln index 107ee66..f10af20 100644 --- a/Pinecone.sln +++ b/Pinecone.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.CSharp", "example\E EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Example.FSharp", "example\Example.FSharp\Example.FSharp.fsproj", "{48198DC6-2D75-417F-8096-9FF9AC657511}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PineconeTests", "test\PineconeTests.csproj", "{122FC8A9-A7F9-43DA-A67E-4148D07B9597}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {48198DC6-2D75-417F-8096-9FF9AC657511}.Debug|Any CPU.Build.0 = Debug|Any CPU {48198DC6-2D75-417F-8096-9FF9AC657511}.Release|Any CPU.ActiveCfg = Release|Any CPU {48198DC6-2D75-417F-8096-9FF9AC657511}.Release|Any CPU.Build.0 = Release|Any CPU + {122FC8A9-A7F9-43DA-A67E-4148D07B9597}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {122FC8A9-A7F9-43DA-A67E-4148D07B9597}.Debug|Any CPU.Build.0 = Debug|Any CPU + {122FC8A9-A7F9-43DA-A67E-4148D07B9597}.Release|Any CPU.ActiveCfg = Release|Any CPU + {122FC8A9-A7F9-43DA-A67E-4148D07B9597}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/example/Example.CSharp/Program.cs b/example/Example.CSharp/Program.cs index 4efb71d..b249ae6 100644 --- a/example/Example.CSharp/Program.cs +++ b/example/Example.CSharp/Program.cs @@ -1,16 +1,17 @@ using Pinecone; -using var pinecone = new PineconeClient("[api-key]", "[pinecone-env]"); +using var pinecone = new PineconeClient("[api-key]"); // Check if the index exists and create it if it doesn't // Depending on the storage type and infrastructure state this may take a while -// Free tier is limited to 1 index only +// Free tier is limited to 1 pod index and 5 serverless indexes only var indexName = "test-index"; var indexList = await pinecone.ListIndexes(); -if (!indexList.Contains(indexName)) +if (!indexList.Select(x => x.Name).Contains(indexName)) { - await pinecone.CreateIndex(indexName, 1536, Metric.Cosine); + // free serverless indexes are currently only available on AWS us-east-1 + await pinecone.CreateServerlessIndexAsync(indexName, 1536, Metric.Cosine, "aws", "us-east-1"); } // Get the Pinecone index by name (uses gRPC by default). diff --git a/example/Example.FSharp/Program.fs b/example/Example.FSharp/Program.fs index a092ed1..1a06f8d 100644 --- a/example/Example.FSharp/Program.fs +++ b/example/Example.FSharp/Program.fs @@ -5,16 +5,17 @@ let createMetadata x = MetadataMap(x |> Seq.map (fun (k, m) -> KeyValuePair(k,m) )) let main = task { - use pinecone = new PineconeClient("[api-key]", "[pinecone-env]") + use pinecone = new PineconeClient("[api-key]") // Check if the index exists and create it if it doesn't // Depending on the storage type and infrastructure state this may take a while - // Free tier is limited to 1 index only + // Free tier is limited to 1 pod index and 5 serverless indexes only let indexName = "test-index" let! indexList = pinecone.ListIndexes() - if indexList |> Array.contains indexName |> not then - do! pinecone.CreateIndex(indexName, 1536u, Metric.Cosine) - + if not (indexList |> Array.exists (fun index -> index.Name = indexName)) then + // Create the serverless index (available only on AWS us-east-1) + pinecone.CreateServerlessIndexAsync(indexName, 1536u, Metric.Cosine, "aws", "us-east-1") + // Get the Pinecone index by name (uses gRPC by default). // The index client is thread-safe, consider caching and/or // injecting it as a singleton into your DI container. diff --git a/src/Grpc/GrpcTransport.cs b/src/Grpc/GrpcTransport.cs index 196f569..9b997ba 100644 --- a/src/Grpc/GrpcTransport.cs +++ b/src/Grpc/GrpcTransport.cs @@ -33,7 +33,10 @@ public async Task DescribeStats(MetadataMap? filter = null) } using var call = Grpc.DescribeIndexStatsAsync(request, Auth); - return (await call.ConfigureAwait(false)).ToPublicType(); + var response = await call.ConfigureAwait(false); + + return response.ToPublicType(); + //return (await call.ConfigureAwait(false)).ToPublicType(); } public async Task Query( @@ -89,7 +92,10 @@ public async Task Upsert(IEnumerable vectors, string? indexNamespa request.Vectors.AddRange(vectors.Select(v => v.ToProtoVector())); using var call = Grpc.UpsertAsync(request, Auth); - return (await call.ConfigureAwait(false)).UpsertedCount; + var response = await call.ConfigureAwait(false); + + return response.UpsertedCount; + //return (await call.ConfigureAwait(false)).UpsertedCount; } public Task Update(Vector vector, string? indexNamespace = null) => Update( diff --git a/src/Index.cs b/src/Index.cs index 3ee2f05..59e77af 100644 --- a/src/Index.cs +++ b/src/Index.cs @@ -10,9 +10,11 @@ public sealed partial record Index< #endif TTransport> where TTransport : ITransport { - [JsonPropertyName("database")] - public required IndexDetails Details { get; init; } - + public required string Name { get; init; } + public required uint Dimension { get; init; } + public required Metric Metric { get; init; } + public string? Host { get; init; } + public required IndexSpec Spec { get; init; } public required IndexStatus Status { get; init; } } diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index d4306d5..26d34f1 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -10,6 +10,12 @@ namespace Pinecone; public sealed class PineconeClient : IDisposable { private readonly HttpClient Http; + private readonly string? _legacyEnvironment; + + public PineconeClient(string apiKey) + : this(apiKey, new Uri($"https://api.pinecone.io")) + { + } public PineconeClient(string apiKey, string environment) { @@ -18,6 +24,7 @@ public PineconeClient(string apiKey, string environment) Http = new() { BaseAddress = new Uri($"https://controller.{environment}.pinecone.io") }; Http.DefaultRequestHeaders.Add("Api-Key", apiKey); + _legacyEnvironment = environment; } public PineconeClient(string apiKey, Uri baseUrl) @@ -38,28 +45,48 @@ public PineconeClient(string apiKey, HttpClient client) Http.DefaultRequestHeaders.Add("Api-Key", apiKey); } - public async Task ListIndexes() + public async Task ListIndexes() { - var indexes = await Http - .GetFromJsonAsync("/databases", SerializerContext.Default.StringArray) + var listIndexesResult = (ListIndexesResult?)await Http + .GetFromJsonAsync("/indexes", typeof(ListIndexesResult), SerializerContext.Default) .ConfigureAwait(false); - return indexes ?? []; + return listIndexesResult?.Indexes ?? []; } - public Task CreateIndex(string name, uint dimension, Metric metric) => - CreateIndex(new IndexDetails { Name = name, Dimension = dimension, Metric = metric }); + public Task CreatePodIndexAsync(string name, uint dimiension, Metric metric, string environment, string podType, long pods) + => CreateIndexAsync(new CreateIndexRequest + { + Name = name, + Dimension = dimiension, + Metric = metric, + Spec = new IndexSpec { Pod = new PodSpec { Environment = environment, PodType = podType, Pods = pods } } + }); + + public Task CreateServerlessIndexAsync(string name, uint dimiension, Metric metric, string cloud, string region) + => CreateIndexAsync(new CreateIndexRequest + { + Name = name, + Dimension = dimiension, + Metric = metric, + Spec = new IndexSpec { Serverless = new ServerlessSpec { Cloud = cloud, Region = region } } + }); - public async Task CreateIndex(IndexDetails indexDetails, string? sourceCollection = null) + private async Task CreateIndexAsync(CreateIndexRequest request) { - var request = CreateIndexRequest.From(indexDetails, sourceCollection); var response = await Http - .PostAsJsonAsync("/databases", request, SerializerContext.Default.CreateIndexRequest) + .PostAsJsonAsync("/indexes", request, SerializerContext.Default.CreateIndexRequest) .ConfigureAwait(false); await response.CheckStatusCode().ConfigureAwait(false); } + [Obsolete("Use 'CreateServerlessIndexAsync' or 'CreatePodIndexAsync' methods instead.")] + public Task CreateIndex(string name, uint dimension, Metric metric) + => _legacyEnvironment is not null + ? CreatePodIndexAsync(name, dimension, metric, _legacyEnvironment, "starter", 1) + : throw new InvalidOperationException($"Use '{nameof(CreateServerlessIndexAsync)}' or '{nameof(CreatePodIndexAsync)}' methods instead."); + public Task> GetIndex(string name) => GetIndex(name); #if NET7_0_OR_GREATER @@ -70,22 +97,34 @@ public async Task> GetIndex< #endif where TTransport : ITransport { - var response = await Http + var response = (IndexDetails?)await Http .GetFromJsonAsync( - $"/databases/{UrlEncoder.Default.Encode(name)}", - typeof(Index), + $"/indexes/{UrlEncoder.Default.Encode(name)}", + typeof(IndexDetails), SerializerContext.Default) - .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed."); + .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed.")!; - var index = (Index)response; - var host = index.Status.Host; + // TODO: Host is optional according to the API spec: https://docs.pinecone.io/reference/api/control-plane/describe_index + // but Transport requires it + var host = response.Host!; var apiKey = Http.DefaultRequestHeaders.GetValues(Constants.RestApiKey).First(); + var index = new Index + { + Name = response.Name, + Dimension = response.Dimension, + Metric = response.Metric, + Host = response.Host, + Spec = response.Spec, + Status = response.Status, + }; + #if NET7_0_OR_GREATER index.Transport = TTransport.Create(host, apiKey); #else index.Transport = ITransport.Create(host, apiKey); #endif + return index; } @@ -100,7 +139,7 @@ public async Task ConfigureIndex(string name, int? replicas = null, string? podT var request = new ConfigureIndexRequest { Replicas = replicas, PodType = podType }; var response = await Http .PatchAsJsonAsync( - $"/databases/{UrlEncoder.Default.Encode(name)}", + $"/indexes/{UrlEncoder.Default.Encode(name)}", request, SerializerContext.Default.ConfigureIndexRequest) .ConfigureAwait(false); @@ -109,17 +148,17 @@ public async Task ConfigureIndex(string name, int? replicas = null, string? podT } public async Task DeleteIndex(string name) => - await (await Http.DeleteAsync($"/databases/{UrlEncoder.Default.Encode(name)}").ConfigureAwait(false)) + await (await Http.DeleteAsync($"/indexes/{UrlEncoder.Default.Encode(name)}").ConfigureAwait(false)) .CheckStatusCode() .ConfigureAwait(false); - public async Task ListCollections() + public async Task ListCollections() { - var collections = await Http - .GetFromJsonAsync("/collections", SerializerContext.Default.StringArray) + var listCollectionsResult = (ListCollectionsResult?)await Http + .GetFromJsonAsync("/collections", typeof(ListCollectionsResult), SerializerContext.Default) .ConfigureAwait(false); - return collections ?? []; + return listCollectionsResult?.Collections ?? []; } public async Task CreateCollection(string name, string source) diff --git a/src/Rest/SerializerContext.cs b/src/Rest/SerializerContext.cs index 4e67f1d..8a80bf0 100644 --- a/src/Rest/SerializerContext.cs +++ b/src/Rest/SerializerContext.cs @@ -25,6 +25,8 @@ namespace Pinecone.Rest; [JsonSerializable(typeof(DeleteRequest))] [JsonSerializable(typeof(MetadataMap))] [JsonSerializable(typeof(MetadataValue[]))] +[JsonSerializable(typeof(ListIndexesResult))] +[JsonSerializable(typeof(ListCollectionsResult))] [JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Default, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, diff --git a/src/Rest/Types.cs b/src/Rest/Types.cs index 804fdc8..1a3e431 100644 --- a/src/Rest/Types.cs +++ b/src/Rest/Types.cs @@ -2,24 +2,12 @@ namespace Pinecone.Rest; -internal sealed record CreateIndexRequest : IndexDetails +internal sealed record CreateIndexRequest { - [JsonPropertyName("source_collection")] - public string? SourceCollection { get; init; } - - public static CreateIndexRequest From( - IndexDetails index, - string? sourceCollection) => new() - { - Dimension = index.Dimension, - Metric = index.Metric, - Name = index.Name, - Pods = index.Pods, - PodType = index.PodType, - Replicas = index.Replicas, - MetadataConfig = index.MetadataConfig, - SourceCollection = sourceCollection - }; + public required string Name { get; init; } + public required uint Dimension { get; init; } + public required Metric Metric { get; init; } + public required IndexSpec Spec { get; init; } } internal readonly record struct ConfigureIndexRequest diff --git a/src/Types/CollectionTypes.cs b/src/Types/CollectionTypes.cs index 187f249..fe66192 100644 --- a/src/Types/CollectionTypes.cs +++ b/src/Types/CollectionTypes.cs @@ -2,12 +2,25 @@ namespace Pinecone; +public record ListCollectionsResult +{ + public required CollectionDetails[] Collections { get; init; } +} + public record CollectionDetails { public required string Name { get; init; } - public required long Size { get; init; } - public required string Status { get; init; } - public required long Dimension { get; init; } + public long? Size { get; init; } + public required CollectionStatus Status { get; init; } + public required uint Dimension { get; init; } [JsonPropertyName("vector_count")] public required long VectorCount { get; init; } + public required string Environment { get; init; } +} + +public enum CollectionStatus +{ + Initializing = 0, + Ready = 1, + Terminating = 2 } diff --git a/src/Types/IndexTypes.cs b/src/Types/IndexTypes.cs index 9e536a6..42602ae 100644 --- a/src/Types/IndexTypes.cs +++ b/src/Types/IndexTypes.cs @@ -3,17 +3,20 @@ namespace Pinecone; +public record ListIndexesResult +{ + public required IndexDetails[] Indexes { get; init; } +} + public record IndexDetails { public required string Name { get; init; } public required uint Dimension { get; init; } public required Metric Metric { get; init; } - public long? Pods { get; init; } - [JsonPropertyName("pod_type")] - public string? PodType { get; init; } - public long? Replicas { get; init; } - [JsonPropertyName("metadata_config")] - public MetadataMap? MetadataConfig { get; init; } + public string? Host { get; init; } + + public required IndexSpec Spec { get; init;} + public required IndexStatus Status { get; init; } } [JsonConverter(typeof(JsonStringEnumConverter))] @@ -29,10 +32,6 @@ public record IndexStatus [JsonPropertyName("ready")] public required bool IsReady { get; init; } public required IndexState State { get; init; } - public required string Host { get; init; } - public required int Port { get; init; } - public string?[]? Waiting { get; init; } - public string?[]? Crashed { get; init; } } [JsonConverter(typeof(JsonStringEnumConverter))] @@ -48,6 +47,35 @@ public enum IndexState InitializationFailed = 7 } +public record IndexSpec +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ServerlessSpec? Serverless { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PodSpec? Pod { get; init; } +} + +public record ServerlessSpec +{ + public required string Cloud { get; init; } + public required string Region { get; init; } +} + +public record PodSpec +{ + [JsonPropertyName("environment")] + public required string Environment { get; init; } + public long? Replicas { get; init; } + [JsonPropertyName("pod_type")] + public required string PodType { get; init; } + public long Pods { get; init; } + [JsonPropertyName("metadata_config")] + public MetadataMap? MetadataConfig { get; init; } + [JsonPropertyName("source_collection")] + public string? SourceCollection { get; init; } +} + public record IndexStats { [JsonConverter(typeof(IndexNamespaceArrayConverter))] diff --git a/test/DataTests.cs b/test/DataTests.cs new file mode 100644 index 0000000..b4f1a72 --- /dev/null +++ b/test/DataTests.cs @@ -0,0 +1,419 @@ +using Pinecone; +using Pinecone.Grpc; +using Xunit; + +namespace PineconeTests; + +[Collection("PineconeTests")] +public class DataTests(DataTests.TestFixture fixture) : IClassFixture +{ + private TestFixture Fixture { get; } = fixture; + + [Fact] + public async Task Basic_query() + { + var x = 0.314f; + + var results = await Fixture.Index.Query( + [x * 0.1f, x * 0.2f, x * 0.3f, x * 0.4f, x * 0.5f, x * 0.6f, x * 0.7f, x * 0.8f], + topK: 10); + + Assert.Equal(8, results.Length); + + results = + await Fixture.Index.Query( + [0.7f, 7.7f, 77.7f, 777.7f, 7777.7f, 77777.7f, 777777.7f, 7777777.7f], + topK: 10, + indexNamespace: "namespace1"); + + Assert.Equal(3, results.Length); + } + + [Fact] + public async Task Query_by_Id() + { + var result = await Fixture.Index.Query("basic-vector-3", topK: 2); + + // NOTE: query by id uses Approximate Nearest Neighbor, which doesn't guarantee the input vector + // to appear in the results, so we just check the result count + Assert.Equal(2, result.Length); + } + + [Fact] + public async Task Query_with_basic_metadata_filter() + { + var filter = new MetadataMap + { + ["type"] = "number set" + }; + + var result = await Fixture.Index.Query([3, 4, 5, 6, 7, 8, 9, 10], topK: 5, filter); + + Assert.Equal(3, result.Length); + var ordered = result.OrderBy(x => x.Id).ToList(); + + Assert.Equal("metadata-vector-1", ordered[0].Id); + Assert.Equal([2, 3, 5, 7, 11, 13, 17, 19], ordered[0].Values); + Assert.Equal("metadata-vector-2", ordered[1].Id); + Assert.Equal([0, 1, 1, 2, 3, 5, 8, 13], ordered[1].Values); + Assert.Equal("metadata-vector-3", ordered[2].Id); + Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[2].Values); + } + + [Fact] + public async Task Query_include_metadata_in_result() + { + var filter = new MetadataMap + { + ["subtype"] = "fibo" + }; + + var result = await Fixture.Index.Query([3, 4, 5, 6, 7, 8, 9, 10], topK: 5, filter, includeMetadata: true); + + Assert.Single(result); + Assert.Equal("metadata-vector-2", result[0].Id); + Assert.Equal([0, 1, 1, 2, 3, 5, 8, 13], result[0].Values); + var metadata = result[0].Metadata; + Assert.NotNull(metadata); + + Assert.Equal("number set",metadata["type"]); + Assert.Equal("fibo", metadata["subtype"]); + + var innerList = (MetadataValue[])metadata["list"].Inner!; + Assert.Equal("0", innerList[0]); + Assert.Equal("1", innerList[1]); + } + + [Fact] + public async Task Query_with_metadata_filter_composite() + { + var filter = new MetadataMap + { + ["type"] = "number set", + ["overhyped"] = false + }; + + var result = await Fixture.Index.Query([3, 4, 5, 6, 7, 8, 9, 10], topK: 5, filter); + + Assert.Equal(2, result.Length); + var ordered = result.OrderBy(x => x.Id).ToList(); + + Assert.Equal("metadata-vector-1", ordered[0].Id); + Assert.Equal([2, 3, 5, 7, 11, 13, 17, 19], ordered[0].Values); + Assert.Equal("metadata-vector-3", ordered[1].Id); + Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[1].Values); + } + + [Fact] + public async Task Query_with_metadata_list_contains() + { + var filter = new MetadataMap + { + ["rank"] = new MetadataMap() { ["$in"] = new int[] { 12, 3 } } + }; + + var result = await Fixture.Index.Query([3, 4, 5, 6, 7, 8, 9, 10], topK: 10, filter, includeMetadata: true); + + Assert.Equal(2, result.Length); + var ordered = result.OrderBy(x => x.Id).ToList(); + + Assert.Equal("metadata-vector-1", ordered[0].Id); + Assert.Equal([2, 3, 5, 7, 11, 13, 17, 19], ordered[0].Values); + Assert.Equal("metadata-vector-3", ordered[1].Id); + Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[1].Values); + } + + [Fact] + public async Task Basic_fetch() + { + var results = await Fixture.Index.Fetch(["basic-vector-1", "basic-vector-3"]); + var orderedResults = results.OrderBy(x => x.Key).ToList(); + + Assert.Equal(2, orderedResults.Count); + + Assert.Equal("basic-vector-1", orderedResults[0].Key); + Assert.Equal("basic-vector-1", orderedResults[0].Value.Id); + Assert.Equal([0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f], orderedResults[0].Value.Values); + + Assert.Equal("basic-vector-3", orderedResults[1].Key); + Assert.Equal("basic-vector-3", orderedResults[1].Value.Id); + Assert.Equal([1.5f, 3.0f, 4.5f, 6.0f, 7.5f, 9.0f, 10.5f, 12.0f], orderedResults[1].Value.Values); + } + + [Fact] + public async Task Basic_vector_upsert_update_delete() + { + var testNamespace = "upsert-update-delete-namespace"; + var newVectors = new Vector[] + { + new() { Id = "update-vector-id-1", Values = [1, 3, 5, 7, 9, 11, 13, 15] }, + new() { Id = "update-vector-id-2", Values = [2, 3, 5, 7, 11, 13, 17, 19] }, + new() { Id = "update-vector-id-3", Values = [2, 1, 3, 4, 7, 11, 18, 29] }, + }; + + await Fixture.InsertAndWait(newVectors, testNamespace); + + var initialFetch = await Fixture.Index.Fetch(["update-vector-id-2"], testNamespace); + var vector = initialFetch["update-vector-id-2"]; + vector.Values[0] = 23; + await Fixture.Index.Update(vector, testNamespace); + + Vector updatedVector; + var attemptCount = 0; + do + { + await Task.Delay(TestFixture.DelayInterval); + attemptCount++; + var finalFetch = await Fixture.Index.Fetch(["update-vector-id-2"], testNamespace); + updatedVector = finalFetch["update-vector-id-2"]; + } while (updatedVector.Values[0] != 23 && attemptCount < TestFixture.MaxAttemptCount); + + Assert.Equal("update-vector-id-2", updatedVector.Id); + Assert.Equal([23, 3, 5, 7, 11, 13, 17, 19], updatedVector.Values); + + await Fixture.DeleteAndWait(["update-vector-id-1"], testNamespace); + + var stats = await Fixture.Index.DescribeStats(); + Assert.Equal((uint)2, stats.Namespaces.Where(x => x.Name == testNamespace).Select(x => x.VectorCount).SingleOrDefault()); + + await Fixture.DeleteAndWait(["update-vector-id-2", "update-vector-id-3"], testNamespace); + } + + [Fact] + public async Task Upsert_on_existing_vector_makes_an_update() + { + var testNamespace = "upsert-on-existing"; + var newVectors = new Vector[] + { + new() { Id = "update-vector-id-1", Values = [1, 3, 5, 7, 9, 11, 13, 15] }, + new() { Id = "update-vector-id-2", Values = [2, 3, 5, 7, 11, 13, 17, 19] }, + new() { Id = "update-vector-id-3", Values = [2, 1, 3, 4, 7, 11, 18, 29] }, + }; + + await Fixture.InsertAndWait(newVectors, testNamespace); + + var newExistingVector = new Vector() { Id = "update-vector-id-3", Values = [0, 1, 1, 2, 3, 5, 8, 13] }; + + await Fixture.Index.Upsert([newExistingVector], testNamespace); + + Vector updatedVector; + var attemptCount = 0; + do + { + await Task.Delay(TestFixture.DelayInterval); + attemptCount++; + var finalFetch = await Fixture.Index.Fetch(["update-vector-id-3"], testNamespace); + updatedVector = finalFetch["update-vector-id-3"]; + } while (updatedVector.Values[0] != 0 && attemptCount < TestFixture.MaxAttemptCount); + + Assert.Equal("update-vector-id-3", updatedVector.Id); + Assert.Equal([0, 1, 1, 2, 3, 5, 8, 13], updatedVector.Values); + } + + [Fact] + public async Task Delete_all_vectors_from_namespace() + { + var testNamespace = "delete-all-namespace"; + var newVectors = new Vector[] + { + new() { Id = "delete-all-vector-id-1", Values = [1, 3, 5, 7, 9, 11, 13, 15] }, + new() { Id = "delete-all-vector-id-2", Values = [2, 3, 5, 7, 11, 13, 17, 19] }, + new() { Id = "delete-all-vector-id-3", Values = [2, 1, 3, 4, 7, 11, 18, 29] }, + }; + + await Fixture.InsertAndWait(newVectors, testNamespace); + + await Fixture.Index.DeleteAll(testNamespace); + + IndexStats stats; + var attemptCount = 0; + do + { + await Task.Delay(TestFixture.DelayInterval); + attemptCount++; + stats = await Fixture.Index.DescribeStats(); + } while (stats.Namespaces.Where(x => x.Name == testNamespace).Select(x => x.VectorCount).SingleOrDefault() > 0 + && attemptCount <= TestFixture.MaxAttemptCount); + + Assert.Equal((uint)0, stats.Namespaces.Where(x => x.Name == testNamespace).Select(x => x.VectorCount).SingleOrDefault()); + } + + [Fact] + public async Task Delete_vector_that_doesnt_exist() + { + await Fixture.Index.Delete(["non-existing-index"]); + } + + public class TestFixture : IAsyncLifetime + { + // 10s with 100ms intervals + public const int MaxAttemptCount = 100; + public const int DelayInterval = 100; + private const string IndexName = "serverless-data-tests"; + + public PineconeClient Pinecone { get; private set; } = null!; + public Index Index { get; private set; } = null!; + + public async Task InitializeAsync() + { + Pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + + await ClearIndexesAsync(); + await CreateIndexAndWait(); + await AddSampleDataAsync(); + } + + private async Task CreateIndexAndWait() + { + var attemptCount = 0; + await Pinecone.CreateServerlessIndexAsync(IndexName, dimiension: 8, metric: Metric.Euclidean, cloud: "aws", region: "us-east-1"); + + do + { + await Task.Delay(DelayInterval); + attemptCount++; + Index = await Pinecone.GetIndex(IndexName); + } while (!Index.Status.IsReady && attemptCount <= MaxAttemptCount); + + if (!Index.Status.IsReady) + { + throw new InvalidOperationException("'Create index' operation didn't complete in time. Index name: " + IndexName); + } + } + + private async Task AddSampleDataAsync() + { + var basicVectors = Enumerable.Range(1, 5).Select(i => new Vector + { + Id = "basic-vector-" + i, + Values = [i * 0.5f, i * 1.0f, i * 1.5f, i * 2.0f, i * 2.5f, i * 3.0f, i * 3.5f, i * 4.0f], + }).ToList(); + + await InsertAndWait(basicVectors); + + var customNamespaceVectors = Enumerable.Range(1, 3).Select(i => new Vector + { + Id = "custom-namespace-vector-" + i, + Values = [i * 1.1f, i * 2.2f, i * 3.3f, i * 4.4f, i * 5.5f, i * 6.6f, i * 7.7f, i * 8.8f], + }).ToList(); + + await InsertAndWait(customNamespaceVectors, "namespace1"); + + var metadata1 = new MetadataMap + { + ["type"] = "number set", + ["subtype"] = "primes", + ["rank"] = 3, + ["overhyped"] = false, + ["list"] = new string[] { "2", "1" }, + }; + + var metadata2 = new MetadataMap + { + ["type"] = "number set", + ["subtype"] = "fibo", + ["list"] = new string[] { "0", "1" }, + }; + + var metadata3 = new MetadataMap + { + ["type"] = "number set", + ["subtype"] = "lucas", + ["rank"] = 12, + ["overhyped"] = false, + ["list"] = new string[] { "two", "one" }, + }; + + var metadataVectors = new Vector[] + { + new() { Id = "metadata-vector-1", Values = [2, 3, 5, 7, 11, 13, 17, 19], Metadata = metadata1 }, + new() { Id = "metadata-vector-2", Values = [0, 1, 1, 2, 3, 5, 8, 13], Metadata = metadata2 }, + new() { Id = "metadata-vector-3", Values = [2, 1, 3, 4, 7, 11, 18, 29], Metadata = metadata3 }, + }; + + await InsertAndWait(metadataVectors); + } + + public async Task InsertAndWait(IEnumerable vectors, string? indexNamespace = null) + { + // NOTE: this only works when inserting *new* vectors, if the vector already exisits the new vector count won't match + // and we will have false-negative "failure" to insert + var stats = await Index.DescribeStats(); + var vectorCountBefore = stats.TotalVectorCount; + var attemptCount = 0; + var result = await Index.Upsert(vectors, indexNamespace); + + do + { + await Task.Delay(DelayInterval); + attemptCount++; + stats = await Index.DescribeStats(); + } while (stats.TotalVectorCount < vectorCountBefore + vectors.Count() && attemptCount <= MaxAttemptCount); + + if (stats.TotalVectorCount < vectorCountBefore + vectors.Count()) + { + throw new InvalidOperationException("'Upsert' operation didn't complete in time. Vectors count: " + vectors.Count()); + } + + return result; + } + + public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace = null) + { + var stats = await Index.DescribeStats(); + var vectorCountBefore = stats.Namespaces.Single(x => x.Name == (indexNamespace ?? "")).VectorCount; + + var attemptCount = 0; + await Index.Delete(ids, indexNamespace); + long vectorCount; + do + { + await Task.Delay(DelayInterval); + attemptCount++; + stats = await Index.DescribeStats(); + vectorCount = stats.Namespaces.Single(x => x.Name == (indexNamespace ?? "")).VectorCount; + } while (vectorCount > vectorCountBefore - ids.Count() && attemptCount <= MaxAttemptCount); + + if (vectorCount > vectorCountBefore - ids.Count()) + { + throw new InvalidOperationException("'Delete' operation didn't complete in time."); + } + } + + public async Task DisposeAsync() + { + await ClearIndexesAsync(); + Pinecone.Dispose(); + } + + private async Task ClearIndexesAsync() + { + foreach (var existingIndex in await Pinecone.ListIndexes()) + { + await DeleteExistingIndexAndWaitAsync(existingIndex.Name); + } + } + + private async Task DeleteExistingIndexAndWaitAsync(string indexName) + { + var exists = true; + var attemptCount = 0; + await Pinecone.DeleteIndex(indexName); + + do + { + await Task.Delay(DelayInterval); + var indexes = (await Pinecone.ListIndexes()).Select(x => x.Name).ToArray(); + if (indexes.Length == 0 || !indexes.Contains(indexName)) + { + exists = false; + } + } while (exists && attemptCount <= MaxAttemptCount); + + if (exists) + { + throw new InvalidOperationException("'Delete index' operation didn't complete in time. Index name: " + indexName); + } + } + } +} \ No newline at end of file diff --git a/test/IndexTests.cs b/test/IndexTests.cs new file mode 100644 index 0000000..a4852c6 --- /dev/null +++ b/test/IndexTests.cs @@ -0,0 +1,103 @@ +using Pinecone; +using Pinecone.Grpc; +using Xunit; + +namespace PineconeTests; + +[Collection("PineconeTests")] +public class IndexTests +{ + private const int MaxAttemptCount = 300; + private const int DelayInterval = 100; + + [Fact] + public async Task Legacy_index_sandbox() + { + var indexName = "legacy-pod-based-index"; + + var pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + + // check all existing indexes + var podIndexes = (await pinecone.ListIndexes()).Where(x => x.Spec.Pod is not null).Select(x => x.Name).ToList(); + foreach (var podIndex in podIndexes) + { + // delete the previous pod-based index (only one is allowed on free plan) + await pinecone.DeleteIndex(indexName); + } + + var attemptCount = 0; + // wait until old index has been deleted + do + { + await Task.Delay(DelayInterval); + attemptCount++; + podIndexes = (await pinecone.ListIndexes()).Where(x => x.Spec.Pod is not null).Select(x => x.Name).ToList(); + } + while (podIndexes.Any() && attemptCount < MaxAttemptCount); + + //this will get created but initialization fails later + await pinecone.CreatePodIndexAsync(indexName, 3, Metric.Cosine, "gcp-starter", "starter", 1); + + var listIndexes = await pinecone.ListIndexes(); + + Assert.Contains(indexName, listIndexes.Select(x => x.Name)); + } + + [Theory] + [InlineData(Metric.DotProduct)] + [InlineData(Metric.Cosine)] + [InlineData(Metric.Euclidean)] + public async Task Create_and_delete_serverless_index(Metric metric) + { + var indexName = "serverless-index"; + + var pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + + // check for existing index + var podIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); + if (podIndexes.Contains(indexName)) + { + // delete the previous index + await pinecone.DeleteIndex(indexName); + } + + var attemptCount = 0; + // wait until old index has been deleted + do + { + await Task.Delay(DelayInterval); + attemptCount++; + podIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); + } + while (podIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); + + await pinecone.CreateServerlessIndexAsync(indexName, 3, metric, "aws", "us-east-1"); + + Index index; + attemptCount = 0; + do + { + await Task.Delay(DelayInterval); + attemptCount++; + index = await pinecone.GetIndex(indexName); + } + while (!index.Status.IsReady && attemptCount < MaxAttemptCount); + + var listIndexes = await pinecone.ListIndexes(); + + // validate + Assert.Contains(indexName, listIndexes.Select(x => x.Name)); + Assert.Equal((uint)3, index.Dimension); + Assert.Equal(metric, index.Metric); + + // cleanup + await pinecone.DeleteIndex(indexName); + } + + [Fact] + public async Task List_collections() + { + var pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + var collections = await pinecone.ListCollections(); + } +} diff --git a/test/PineconeTests.csproj b/test/PineconeTests.csproj new file mode 100644 index 0000000..9ca4dab --- /dev/null +++ b/test/PineconeTests.csproj @@ -0,0 +1,29 @@ + + + + net6.0;net7.0;net8.0 + nullable + true + enable + 12 + enable + PineconeTests + 1af43fd0-bcf9-4d57-89db-d842f94175a2 + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + diff --git a/test/UserSecretsExtensions.cs b/test/UserSecretsExtensions.cs new file mode 100644 index 0000000..2d656a0 --- /dev/null +++ b/test/UserSecretsExtensions.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using System.Text.Json; +using Microsoft.Extensions.Configuration.UserSecrets; + +namespace PineconeTests; + +public class UserSecrets +{ + public static string Read(string key) + => JsonSerializer.Deserialize>( + File.ReadAllText(PathHelper.GetSecretsPathFromSecretsId( + typeof(UserSecrets).Assembly.GetCustomAttribute()! + .UserSecretsId)))![key]; +} \ No newline at end of file From 584cf3cdde7d3938eaccbcd5d9027ccc946ba547 Mon Sep 17 00:00:00 2001 From: maumar Date: Sun, 5 May 2024 18:37:51 -0700 Subject: [PATCH 02/15] removing async suffixes from create index methods and some minor code cleanup --- example/Example.CSharp/Program.cs | 2 +- example/Example.FSharp/Program.fs | 4 ++-- src/Grpc/GrpcTransport.cs | 8 ++------ src/PineconeClient.cs | 14 +++++++------- test/DataTests.cs | 2 +- test/IndexTests.cs | 4 ++-- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/example/Example.CSharp/Program.cs b/example/Example.CSharp/Program.cs index b249ae6..0f71fd7 100644 --- a/example/Example.CSharp/Program.cs +++ b/example/Example.CSharp/Program.cs @@ -11,7 +11,7 @@ if (!indexList.Select(x => x.Name).Contains(indexName)) { // free serverless indexes are currently only available on AWS us-east-1 - await pinecone.CreateServerlessIndexAsync(indexName, 1536, Metric.Cosine, "aws", "us-east-1"); + await pinecone.CreateServerlessIndex(indexName, 1536, Metric.Cosine, "aws", "us-east-1"); } // Get the Pinecone index by name (uses gRPC by default). diff --git a/example/Example.FSharp/Program.fs b/example/Example.FSharp/Program.fs index 1a06f8d..4444428 100644 --- a/example/Example.FSharp/Program.fs +++ b/example/Example.FSharp/Program.fs @@ -13,8 +13,8 @@ let main = task { let indexName = "test-index" let! indexList = pinecone.ListIndexes() if not (indexList |> Array.exists (fun index -> index.Name = indexName)) then - // Create the serverless index (available only on AWS us-east-1) - pinecone.CreateServerlessIndexAsync(indexName, 1536u, Metric.Cosine, "aws", "us-east-1") + // free serverless indexes are currently only available on AWS us-east-1 + pinecone.CreateServerlessIndex(indexName, 1536u, Metric.Cosine, "aws", "us-east-1") // Get the Pinecone index by name (uses gRPC by default). // The index client is thread-safe, consider caching and/or diff --git a/src/Grpc/GrpcTransport.cs b/src/Grpc/GrpcTransport.cs index 9b997ba..5544fe0 100644 --- a/src/Grpc/GrpcTransport.cs +++ b/src/Grpc/GrpcTransport.cs @@ -33,10 +33,8 @@ public async Task DescribeStats(MetadataMap? filter = null) } using var call = Grpc.DescribeIndexStatsAsync(request, Auth); - var response = await call.ConfigureAwait(false); - return response.ToPublicType(); - //return (await call.ConfigureAwait(false)).ToPublicType(); + return (await call.ConfigureAwait(false)).ToPublicType(); } public async Task Query( @@ -92,10 +90,8 @@ public async Task Upsert(IEnumerable vectors, string? indexNamespa request.Vectors.AddRange(vectors.Select(v => v.ToProtoVector())); using var call = Grpc.UpsertAsync(request, Auth); - var response = await call.ConfigureAwait(false); - return response.UpsertedCount; - //return (await call.ConfigureAwait(false)).UpsertedCount; + return (await call.ConfigureAwait(false)).UpsertedCount; } public Task Update(Vector vector, string? indexNamespace = null) => Update( diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index 26d34f1..2a4521d 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -54,7 +54,7 @@ public async Task ListIndexes() return listIndexesResult?.Indexes ?? []; } - public Task CreatePodIndexAsync(string name, uint dimiension, Metric metric, string environment, string podType, long pods) + public Task CreatePodBasedIndex(string name, uint dimiension, Metric metric, string environment, string podType, long pods) => CreateIndexAsync(new CreateIndexRequest { Name = name, @@ -63,7 +63,7 @@ public Task CreatePodIndexAsync(string name, uint dimiension, Metric metric, str Spec = new IndexSpec { Pod = new PodSpec { Environment = environment, PodType = podType, Pods = pods } } }); - public Task CreateServerlessIndexAsync(string name, uint dimiension, Metric metric, string cloud, string region) + public Task CreateServerlessIndex(string name, uint dimiension, Metric metric, string cloud, string region) => CreateIndexAsync(new CreateIndexRequest { Name = name, @@ -81,11 +81,11 @@ private async Task CreateIndexAsync(CreateIndexRequest request) await response.CheckStatusCode().ConfigureAwait(false); } - [Obsolete("Use 'CreateServerlessIndexAsync' or 'CreatePodIndexAsync' methods instead.")] + [Obsolete($"Use '{nameof(CreateServerlessIndex)}' or '{nameof(CreatePodBasedIndex)}' methods instead.")] public Task CreateIndex(string name, uint dimension, Metric metric) => _legacyEnvironment is not null - ? CreatePodIndexAsync(name, dimension, metric, _legacyEnvironment, "starter", 1) - : throw new InvalidOperationException($"Use '{nameof(CreateServerlessIndexAsync)}' or '{nameof(CreatePodIndexAsync)}' methods instead."); + ? CreatePodBasedIndex(name, dimension, metric, _legacyEnvironment, "starter", 1) + : throw new InvalidOperationException($"Use '{nameof(CreateServerlessIndex)}' or '{nameof(CreatePodBasedIndex)}' methods instead."); public Task> GetIndex(string name) => GetIndex(name); @@ -97,12 +97,12 @@ public async Task> GetIndex< #endif where TTransport : ITransport { - var response = (IndexDetails?)await Http + var response = (IndexDetails)(await Http .GetFromJsonAsync( $"/indexes/{UrlEncoder.Default.Encode(name)}", typeof(IndexDetails), SerializerContext.Default) - .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed.")!; + .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed.")); // TODO: Host is optional according to the API spec: https://docs.pinecone.io/reference/api/control-plane/describe_index // but Transport requires it diff --git a/test/DataTests.cs b/test/DataTests.cs index b4f1a72..197296a 100644 --- a/test/DataTests.cs +++ b/test/DataTests.cs @@ -266,7 +266,7 @@ public async Task InitializeAsync() private async Task CreateIndexAndWait() { var attemptCount = 0; - await Pinecone.CreateServerlessIndexAsync(IndexName, dimiension: 8, metric: Metric.Euclidean, cloud: "aws", region: "us-east-1"); + await Pinecone.CreateServerlessIndex(IndexName, dimiension: 8, metric: Metric.Euclidean, cloud: "aws", region: "us-east-1"); do { diff --git a/test/IndexTests.cs b/test/IndexTests.cs index a4852c6..4f9508e 100644 --- a/test/IndexTests.cs +++ b/test/IndexTests.cs @@ -36,7 +36,7 @@ public async Task Legacy_index_sandbox() while (podIndexes.Any() && attemptCount < MaxAttemptCount); //this will get created but initialization fails later - await pinecone.CreatePodIndexAsync(indexName, 3, Metric.Cosine, "gcp-starter", "starter", 1); + await pinecone.CreatePodBasedIndex(indexName, 3, Metric.Cosine, "gcp-starter", "starter", 1); var listIndexes = await pinecone.ListIndexes(); @@ -71,7 +71,7 @@ public async Task Create_and_delete_serverless_index(Metric metric) } while (podIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); - await pinecone.CreateServerlessIndexAsync(indexName, 3, metric, "aws", "us-east-1"); + await pinecone.CreateServerlessIndex(indexName, 3, metric, "aws", "us-east-1"); Index index; attemptCount = 0; From bdd2231b719c3f701da99c9f67dbb3158d6e284d Mon Sep 17 00:00:00 2001 From: maumar Date: Mon, 13 May 2024 17:58:38 -0700 Subject: [PATCH 03/15] adding more tests, skipping tests when pinecone api key is not set --- test/DataTests.cs | 33 ++++---- test/IndexTests.cs | 82 +++++++++---------- test/UserSecretsExtensions.cs | 16 +++- test/Xunit/ITestCondition.cs | 8 ++ .../PineconeApiKeySetConditionAttribute.cs | 14 ++++ test/Xunit/PineconeFactAttribute.cs | 8 ++ test/Xunit/PineconeFactDiscoverer.cs | 17 ++++ test/Xunit/PineconeFactTestCase.cs | 37 +++++++++ test/Xunit/PineconeTheoryAttribute.cs | 8 ++ test/Xunit/PineconeTheoryDiscoverer.cs | 33 ++++++++ test/Xunit/PineconeTheoryTestCase.cs | 35 ++++++++ test/Xunit/XunitTestCaseExtensions.cs | 49 +++++++++++ 12 files changed, 279 insertions(+), 61 deletions(-) create mode 100644 test/Xunit/ITestCondition.cs create mode 100644 test/Xunit/PineconeApiKeySetConditionAttribute.cs create mode 100644 test/Xunit/PineconeFactAttribute.cs create mode 100644 test/Xunit/PineconeFactDiscoverer.cs create mode 100644 test/Xunit/PineconeFactTestCase.cs create mode 100644 test/Xunit/PineconeTheoryAttribute.cs create mode 100644 test/Xunit/PineconeTheoryDiscoverer.cs create mode 100644 test/Xunit/PineconeTheoryTestCase.cs create mode 100644 test/Xunit/XunitTestCaseExtensions.cs diff --git a/test/DataTests.cs b/test/DataTests.cs index 197296a..5ddeca5 100644 --- a/test/DataTests.cs +++ b/test/DataTests.cs @@ -1,15 +1,17 @@ using Pinecone; using Pinecone.Grpc; +using PineconeTests.Xunit; using Xunit; namespace PineconeTests; [Collection("PineconeTests")] +[PineconeApiKeySetCondition] public class DataTests(DataTests.TestFixture fixture) : IClassFixture { private TestFixture Fixture { get; } = fixture; - [Fact] + [PineconeFact] public async Task Basic_query() { var x = 0.314f; @@ -29,7 +31,7 @@ await Fixture.Index.Query( Assert.Equal(3, results.Length); } - [Fact] + [PineconeFact] public async Task Query_by_Id() { var result = await Fixture.Index.Query("basic-vector-3", topK: 2); @@ -39,7 +41,7 @@ public async Task Query_by_Id() Assert.Equal(2, result.Length); } - [Fact] + [PineconeFact] public async Task Query_with_basic_metadata_filter() { var filter = new MetadataMap @@ -60,7 +62,7 @@ public async Task Query_with_basic_metadata_filter() Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[2].Values); } - [Fact] + [PineconeFact] public async Task Query_include_metadata_in_result() { var filter = new MetadataMap @@ -84,7 +86,7 @@ public async Task Query_include_metadata_in_result() Assert.Equal("1", innerList[1]); } - [Fact] + [PineconeFact] public async Task Query_with_metadata_filter_composite() { var filter = new MetadataMap @@ -104,7 +106,7 @@ public async Task Query_with_metadata_filter_composite() Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[1].Values); } - [Fact] + [PineconeFact] public async Task Query_with_metadata_list_contains() { var filter = new MetadataMap @@ -123,7 +125,7 @@ public async Task Query_with_metadata_list_contains() Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[1].Values); } - [Fact] + [PineconeFact] public async Task Basic_fetch() { var results = await Fixture.Index.Fetch(["basic-vector-1", "basic-vector-3"]); @@ -140,7 +142,7 @@ public async Task Basic_fetch() Assert.Equal([1.5f, 3.0f, 4.5f, 6.0f, 7.5f, 9.0f, 10.5f, 12.0f], orderedResults[1].Value.Values); } - [Fact] + [PineconeFact] public async Task Basic_vector_upsert_update_delete() { var testNamespace = "upsert-update-delete-namespace"; @@ -179,7 +181,7 @@ public async Task Basic_vector_upsert_update_delete() await Fixture.DeleteAndWait(["update-vector-id-2", "update-vector-id-3"], testNamespace); } - [Fact] + [PineconeFact] public async Task Upsert_on_existing_vector_makes_an_update() { var testNamespace = "upsert-on-existing"; @@ -210,7 +212,7 @@ public async Task Upsert_on_existing_vector_makes_an_update() Assert.Equal([0, 1, 1, 2, 3, 5, 8, 13], updatedVector.Values); } - [Fact] + [PineconeFact] public async Task Delete_all_vectors_from_namespace() { var testNamespace = "delete-all-namespace"; @@ -238,7 +240,7 @@ public async Task Delete_all_vectors_from_namespace() Assert.Equal((uint)0, stats.Namespaces.Where(x => x.Name == testNamespace).Select(x => x.VectorCount).SingleOrDefault()); } - [Fact] + [PineconeFact] public async Task Delete_vector_that_doesnt_exist() { await Fixture.Index.Delete(["non-existing-index"]); @@ -256,7 +258,7 @@ public class TestFixture : IAsyncLifetime public async Task InitializeAsync() { - Pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + Pinecone = new PineconeClient(UserSecretsExtensions.ReadPineconeApiKey()); await ClearIndexesAsync(); await CreateIndexAndWait(); @@ -382,8 +384,11 @@ public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace public async Task DisposeAsync() { - await ClearIndexesAsync(); - Pinecone.Dispose(); + if (Pinecone is not null) + { + await ClearIndexesAsync(); + Pinecone.Dispose(); + } } private async Task ClearIndexesAsync() diff --git a/test/IndexTests.cs b/test/IndexTests.cs index 4f9508e..c3ec14b 100644 --- a/test/IndexTests.cs +++ b/test/IndexTests.cs @@ -1,80 +1,76 @@ using Pinecone; using Pinecone.Grpc; +using PineconeTests.Xunit; using Xunit; namespace PineconeTests; [Collection("PineconeTests")] +[PineconeApiKeySetCondition] public class IndexTests { private const int MaxAttemptCount = 300; private const int DelayInterval = 100; - [Fact] - public async Task Legacy_index_sandbox() + private async Task DeleteIndexAndWait(PineconeClient pinecone, string indexName) { - var indexName = "legacy-pod-based-index"; - - var pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); - - // check all existing indexes - var podIndexes = (await pinecone.ListIndexes()).Where(x => x.Spec.Pod is not null).Select(x => x.Name).ToList(); - foreach (var podIndex in podIndexes) - { - // delete the previous pod-based index (only one is allowed on free plan) - await pinecone.DeleteIndex(indexName); - } + await pinecone.DeleteIndex(indexName); + List existingIndexes; var attemptCount = 0; // wait until old index has been deleted do { await Task.Delay(DelayInterval); attemptCount++; - podIndexes = (await pinecone.ListIndexes()).Where(x => x.Spec.Pod is not null).Select(x => x.Name).ToList(); + existingIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); } - while (podIndexes.Any() && attemptCount < MaxAttemptCount); - - //this will get created but initialization fails later - await pinecone.CreatePodBasedIndex(indexName, 3, Metric.Cosine, "gcp-starter", "starter", 1); - - var listIndexes = await pinecone.ListIndexes(); - - Assert.Contains(indexName, listIndexes.Select(x => x.Name)); + while (existingIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); } - [Theory] - [InlineData(Metric.DotProduct)] - [InlineData(Metric.Cosine)] - [InlineData(Metric.Euclidean)] - public async Task Create_and_delete_serverless_index(Metric metric) + [PineconeTheory] + [InlineData(Metric.DotProduct, true)] + [InlineData(Metric.Cosine, true)] + [InlineData(Metric.Euclidean, true)] + [InlineData(Metric.DotProduct, false)] + [InlineData(Metric.Cosine, false)] + [InlineData(Metric.Euclidean, false)] + public async Task Create_and_delete_index(Metric metric, bool serverless) { - var indexName = "serverless-index"; + var indexName = serverless ? "serverless-index" : "pod-based-index"; - var pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + var pinecone = new PineconeClient(UserSecretsExtensions.ReadPineconeApiKey()); // check for existing index - var podIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); - if (podIndexes.Contains(indexName)) + var existingIndexes = await pinecone.ListIndexes(); + if (existingIndexes.Select(x => x.Name).Contains(indexName)) { // delete the previous index - await pinecone.DeleteIndex(indexName); + await DeleteIndexAndWait(pinecone, indexName); + //await pinecone.DeleteIndex(indexName); } - var attemptCount = 0; - // wait until old index has been deleted - do + // if we create pod-based index, we need to create any previous gcp-starter indexes + // only one pod-based index is allowed on the starter environment + if (!serverless) { - await Task.Delay(DelayInterval); - attemptCount++; - podIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); + foreach (var existingPodBasedIndex in existingIndexes.Where(x => x.Spec.Pod?.Environment == "gcp-starter")) + { + await DeleteIndexAndWait(pinecone, existingPodBasedIndex.Name); + } } - while (podIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); - await pinecone.CreateServerlessIndex(indexName, 3, metric, "aws", "us-east-1"); + if (serverless) + { + await pinecone.CreateServerlessIndex(indexName, 3, metric, "aws", "us-east-1"); + } + else + { + await pinecone.CreatePodBasedIndex(indexName, 3, metric, "gcp-starter", "starter", 1); + } Index index; - attemptCount = 0; + var attemptCount = 0; do { await Task.Delay(DelayInterval); @@ -94,10 +90,10 @@ public async Task Create_and_delete_serverless_index(Metric metric) await pinecone.DeleteIndex(indexName); } - [Fact] + [PineconeFact] public async Task List_collections() { - var pinecone = new PineconeClient(UserSecrets.Read("PineconeApiKey")); + var pinecone = new PineconeClient(UserSecretsExtensions.ReadPineconeApiKey()); var collections = await pinecone.ListCollections(); } } diff --git a/test/UserSecretsExtensions.cs b/test/UserSecretsExtensions.cs index 2d656a0..4370d65 100644 --- a/test/UserSecretsExtensions.cs +++ b/test/UserSecretsExtensions.cs @@ -4,11 +4,19 @@ namespace PineconeTests; -public class UserSecrets +public static class UserSecretsExtensions { - public static string Read(string key) + public const string PineconeApiKeyUserSecretEntry = "PineconeApiKey"; + + public static string ReadPineconeApiKey() + => JsonSerializer.Deserialize>( + File.ReadAllText(PathHelper.GetSecretsPathFromSecretsId( + typeof(UserSecretsExtensions).Assembly.GetCustomAttribute()! + .UserSecretsId)))![PineconeApiKeyUserSecretEntry]; + + public static bool ContainsPineconeApiKey() => JsonSerializer.Deserialize>( File.ReadAllText(PathHelper.GetSecretsPathFromSecretsId( - typeof(UserSecrets).Assembly.GetCustomAttribute()! - .UserSecretsId)))![key]; + typeof(UserSecretsExtensions).Assembly.GetCustomAttribute()! + .UserSecretsId)))!.ContainsKey(PineconeApiKeyUserSecretEntry); } \ No newline at end of file diff --git a/test/Xunit/ITestCondition.cs b/test/Xunit/ITestCondition.cs new file mode 100644 index 0000000..5e43c9a --- /dev/null +++ b/test/Xunit/ITestCondition.cs @@ -0,0 +1,8 @@ +namespace PineconeTests.Xunit; + +public interface ITestCondition +{ + ValueTask IsMetAsync(); + + string SkipReason { get; } +} \ No newline at end of file diff --git a/test/Xunit/PineconeApiKeySetConditionAttribute.cs b/test/Xunit/PineconeApiKeySetConditionAttribute.cs new file mode 100644 index 0000000..9266563 --- /dev/null +++ b/test/Xunit/PineconeApiKeySetConditionAttribute.cs @@ -0,0 +1,14 @@ +namespace PineconeTests.Xunit; + +public sealed class PineconeApiKeySetConditionAttribute : Attribute, ITestCondition +{ + public ValueTask IsMetAsync() + { + var isMet = UserSecretsExtensions.ContainsPineconeApiKey(); + + return ValueTask.FromResult(isMet); + } + + public string SkipReason + => $"Pinecone API key was not specified in user secrets. Use the following command to set it: dotnet user-secrets set \"{UserSecretsExtensions.PineconeApiKeyUserSecretEntry}\" \"[your Pinecone API key]\""; +} \ No newline at end of file diff --git a/test/Xunit/PineconeFactAttribute.cs b/test/Xunit/PineconeFactAttribute.cs new file mode 100644 index 0000000..35763af --- /dev/null +++ b/test/Xunit/PineconeFactAttribute.cs @@ -0,0 +1,8 @@ +using Xunit; +using Xunit.Sdk; + +namespace PineconeTests.Xunit; + +[AttributeUsage(AttributeTargets.Method)] +[XunitTestCaseDiscoverer("PineconeTests.Xunit.PineconeFactDiscoverer", "PineconeTests")] +public sealed class PineconeFactAttribute : FactAttribute; \ No newline at end of file diff --git a/test/Xunit/PineconeFactDiscoverer.cs b/test/Xunit/PineconeFactDiscoverer.cs new file mode 100644 index 0000000..69b559c --- /dev/null +++ b/test/Xunit/PineconeFactDiscoverer.cs @@ -0,0 +1,17 @@ +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace PineconeTests.Xunit; + +public class PineconeFactDiscoverer(IMessageSink messageSink) : FactDiscoverer(messageSink) +{ + protected override IXunitTestCase CreateTestCase( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo factAttribute) + => new PineconeFactTestCase( + DiagnosticMessageSink, + discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), + testMethod); +} \ No newline at end of file diff --git a/test/Xunit/PineconeFactTestCase.cs b/test/Xunit/PineconeFactTestCase.cs new file mode 100644 index 0000000..905b81e --- /dev/null +++ b/test/Xunit/PineconeFactTestCase.cs @@ -0,0 +1,37 @@ +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace PineconeTests.Xunit; + +public sealed class PineconeFactTestCase : XunitTestCase +{ + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public PineconeFactTestCase() + { + } + + public PineconeFactTestCase( + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + object[]? testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + } + + public override async Task RunAsync( + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + => await XunitTestCaseExtensions.TrySkipAsync(this, messageBus) + ? new RunSummary { Total = 1, Skipped = 1 } + : await base.RunAsync( + diagnosticMessageSink, + messageBus, + constructorArguments, + aggregator, + cancellationTokenSource); +} \ No newline at end of file diff --git a/test/Xunit/PineconeTheoryAttribute.cs b/test/Xunit/PineconeTheoryAttribute.cs new file mode 100644 index 0000000..bc3cf7d --- /dev/null +++ b/test/Xunit/PineconeTheoryAttribute.cs @@ -0,0 +1,8 @@ +using Xunit; +using Xunit.Sdk; + +namespace PineconeTests.Xunit; + +[AttributeUsage(AttributeTargets.Method)] +[XunitTestCaseDiscoverer("PineconeTests.Xunit.PineconeTheoryDiscoverer", "PineconeTests")] +public sealed class PineconeTheoryAttribute : TheoryAttribute; \ No newline at end of file diff --git a/test/Xunit/PineconeTheoryDiscoverer.cs b/test/Xunit/PineconeTheoryDiscoverer.cs new file mode 100644 index 0000000..08acd26 --- /dev/null +++ b/test/Xunit/PineconeTheoryDiscoverer.cs @@ -0,0 +1,33 @@ +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace PineconeTests.Xunit; + +public class PineconeTheoryDiscoverer(IMessageSink messageSink) : TheoryDiscoverer(messageSink) +{ + protected override IEnumerable CreateTestCasesForTheory( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute) + { + yield return new PineconeTheoryTestCase( + DiagnosticMessageSink, + discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), + testMethod); + } + + protected override IEnumerable CreateTestCasesForDataRow( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute, + object[] dataRow) + { + yield return new PineconeFactTestCase( + DiagnosticMessageSink, + discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), + testMethod, + dataRow); + } +} diff --git a/test/Xunit/PineconeTheoryTestCase.cs b/test/Xunit/PineconeTheoryTestCase.cs new file mode 100644 index 0000000..9d0bfeb --- /dev/null +++ b/test/Xunit/PineconeTheoryTestCase.cs @@ -0,0 +1,35 @@ +using PineconeTests.Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +public sealed class PineconeTheoryTestCase : XunitTheoryTestCase +{ + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public PineconeTheoryTestCase() + { + } + + public PineconeTheoryTestCase( + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod) + { + } + + public override async Task RunAsync( + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + => await XunitTestCaseExtensions.TrySkipAsync(this, messageBus) + ? new RunSummary { Total = 1, Skipped = 1 } + : await base.RunAsync( + diagnosticMessageSink, + messageBus, + constructorArguments, + aggregator, + cancellationTokenSource); +} \ No newline at end of file diff --git a/test/Xunit/XunitTestCaseExtensions.cs b/test/Xunit/XunitTestCaseExtensions.cs new file mode 100644 index 0000000..593f670 --- /dev/null +++ b/test/Xunit/XunitTestCaseExtensions.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace PineconeTests.Xunit; + +public static class XunitTestCaseExtensions +{ + private static readonly ConcurrentDictionary> _typeAttributes = new(); + private static readonly ConcurrentDictionary> _assemblyAttributes = new(); + + public static async ValueTask TrySkipAsync(XunitTestCase testCase, IMessageBus messageBus) + { + var method = testCase.Method; + var type = testCase.TestMethod.TestClass.Class; + var assembly = type.Assembly; + + var skipReasons = new List(); + var attributes = + _assemblyAttributes.GetOrAdd( + assembly.Name, + a => assembly.GetCustomAttributes(typeof(ITestCondition)).ToList()) + .Concat( + _typeAttributes.GetOrAdd( + type.Name, + t => type.GetCustomAttributes(typeof(ITestCondition)).ToList())) + .Concat(method.GetCustomAttributes(typeof(ITestCondition))) + .OfType() + .Select(attributeInfo => (ITestCondition)attributeInfo.Attribute); + + foreach (var attribute in attributes) + { + if (!await attribute.IsMetAsync()) + { + skipReasons.Add(attribute.SkipReason); + } + } + + if (skipReasons.Count > 0) + { + messageBus.QueueMessage( + new TestSkipped(new XunitTest(testCase, testCase.DisplayName), string.Join(Environment.NewLine, skipReasons))); + + return true; + } + + return false; + } +} \ No newline at end of file From c3a32ea465b1a8e4e85b06beee3f961e832b719a Mon Sep 17 00:00:00 2001 From: maumar Date: Thu, 16 May 2024 15:45:45 -0700 Subject: [PATCH 04/15] api cleanup, adding cancelation toekn, refactor of tests --- src/PineconeClient.cs | 75 ++++------ test/{DataTests.cs => DataTestBase.cs} | 196 ++----------------------- test/DataTestFixtureBase.cs | 165 +++++++++++++++++++++ test/IndexTests.cs | 35 ++--- test/PodBasedDataTests.cs | 33 +++++ test/ServerlessDataTests.cs | 33 +++++ 6 files changed, 290 insertions(+), 247 deletions(-) rename test/{DataTests.cs => DataTestBase.cs} (54%) create mode 100644 test/DataTestFixtureBase.cs create mode 100644 test/PodBasedDataTests.cs create mode 100644 test/ServerlessDataTests.cs diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index 2a4521d..c11e852 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -10,23 +10,12 @@ namespace Pinecone; public sealed class PineconeClient : IDisposable { private readonly HttpClient Http; - private readonly string? _legacyEnvironment; public PineconeClient(string apiKey) : this(apiKey, new Uri($"https://api.pinecone.io")) { } - public PineconeClient(string apiKey, string environment) - { - Guard.IsNotNullOrWhiteSpace(apiKey); - Guard.IsNotNullOrWhiteSpace(environment); - - Http = new() { BaseAddress = new Uri($"https://controller.{environment}.pinecone.io") }; - Http.DefaultRequestHeaders.Add("Api-Key", apiKey); - _legacyEnvironment = environment; - } - public PineconeClient(string apiKey, Uri baseUrl) { Guard.IsNotNullOrWhiteSpace(apiKey); @@ -45,55 +34,49 @@ public PineconeClient(string apiKey, HttpClient client) Http.DefaultRequestHeaders.Add("Api-Key", apiKey); } - public async Task ListIndexes() + public async Task ListIndexes(CancellationToken cancellationToken = default) { var listIndexesResult = (ListIndexesResult?)await Http - .GetFromJsonAsync("/indexes", typeof(ListIndexesResult), SerializerContext.Default) + .GetFromJsonAsync("/indexes", typeof(ListIndexesResult), SerializerContext.Default, cancellationToken) .ConfigureAwait(false); return listIndexesResult?.Indexes ?? []; } - public Task CreatePodBasedIndex(string name, uint dimiension, Metric metric, string environment, string podType, long pods) + public Task CreatePodBasedIndex(string name, uint dimension, Metric metric, string environment, string podType, long pods, CancellationToken cancellationToken = default) => CreateIndexAsync(new CreateIndexRequest { Name = name, - Dimension = dimiension, + Dimension = dimension, Metric = metric, Spec = new IndexSpec { Pod = new PodSpec { Environment = environment, PodType = podType, Pods = pods } } - }); + }, cancellationToken); - public Task CreateServerlessIndex(string name, uint dimiension, Metric metric, string cloud, string region) + public Task CreateServerlessIndex(string name, uint dimension, Metric metric, string cloud, string region, CancellationToken cancellationToken = default) => CreateIndexAsync(new CreateIndexRequest { Name = name, - Dimension = dimiension, + Dimension = dimension, Metric = metric, Spec = new IndexSpec { Serverless = new ServerlessSpec { Cloud = cloud, Region = region } } - }); + }, cancellationToken); - private async Task CreateIndexAsync(CreateIndexRequest request) + private async Task CreateIndexAsync(CreateIndexRequest request, CancellationToken cancellationToken = default) { var response = await Http - .PostAsJsonAsync("/indexes", request, SerializerContext.Default.CreateIndexRequest) + .PostAsJsonAsync("/indexes", request, SerializerContext.Default.CreateIndexRequest, cancellationToken) .ConfigureAwait(false); await response.CheckStatusCode().ConfigureAwait(false); } - [Obsolete($"Use '{nameof(CreateServerlessIndex)}' or '{nameof(CreatePodBasedIndex)}' methods instead.")] - public Task CreateIndex(string name, uint dimension, Metric metric) - => _legacyEnvironment is not null - ? CreatePodBasedIndex(name, dimension, metric, _legacyEnvironment, "starter", 1) - : throw new InvalidOperationException($"Use '{nameof(CreateServerlessIndex)}' or '{nameof(CreatePodBasedIndex)}' methods instead."); - - public Task> GetIndex(string name) => GetIndex(name); + public Task> GetIndex(string name, CancellationToken cancellationToken = default) => GetIndex(name, cancellationToken); #if NET7_0_OR_GREATER - public async Task> GetIndex(string name) + public async Task> GetIndex(string name, CancellationToken cancellationToken = default) #else public async Task> GetIndex< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransport>(string name) + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransport>(string name, CancellationToken cancellationToken = default) #endif where TTransport : ITransport { @@ -101,7 +84,8 @@ public async Task> GetIndex< .GetFromJsonAsync( $"/indexes/{UrlEncoder.Default.Encode(name)}", typeof(IndexDetails), - SerializerContext.Default) + SerializerContext.Default, + cancellationToken) .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed.")); // TODO: Host is optional according to the API spec: https://docs.pinecone.io/reference/api/control-plane/describe_index @@ -128,7 +112,7 @@ public async Task> GetIndex< return index; } - public async Task ConfigureIndex(string name, int? replicas = null, string? podType = null) + public async Task ConfigureIndex(string name, int? replicas = null, string? podType = null, CancellationToken cancellationToken = default) { if (replicas is null && podType is null or []) { @@ -141,47 +125,52 @@ public async Task ConfigureIndex(string name, int? replicas = null, string? podT .PatchAsJsonAsync( $"/indexes/{UrlEncoder.Default.Encode(name)}", request, - SerializerContext.Default.ConfigureIndexRequest) + SerializerContext.Default.ConfigureIndexRequest, + cancellationToken) .ConfigureAwait(false); await response.CheckStatusCode().ConfigureAwait(false); } - public async Task DeleteIndex(string name) => - await (await Http.DeleteAsync($"/indexes/{UrlEncoder.Default.Encode(name)}").ConfigureAwait(false)) + public async Task DeleteIndex(string name, CancellationToken cancellationToken = default) => + await (await Http.DeleteAsync($"/indexes/{UrlEncoder.Default.Encode(name)}", cancellationToken).ConfigureAwait(false)) .CheckStatusCode() .ConfigureAwait(false); - public async Task ListCollections() + public async Task ListCollections(CancellationToken cancellationToken = default) { var listCollectionsResult = (ListCollectionsResult?)await Http - .GetFromJsonAsync("/collections", typeof(ListCollectionsResult), SerializerContext.Default) + .GetFromJsonAsync("/collections", typeof(ListCollectionsResult), + SerializerContext.Default, + cancellationToken) .ConfigureAwait(false); return listCollectionsResult?.Collections ?? []; } - public async Task CreateCollection(string name, string source) + public async Task CreateCollection(string name, string source, CancellationToken cancellationToken = default) { var request = new CreateCollectionRequest { Name = name, Source = source }; var response = await Http - .PostAsJsonAsync("/collections", request, SerializerContext.Default.CreateCollectionRequest) + .PostAsJsonAsync("/collections", request, SerializerContext.Default.CreateCollectionRequest, + cancellationToken) .ConfigureAwait(false); await response.CheckStatusCode().ConfigureAwait(false); } - public async Task DescribeCollection(string name) + public async Task DescribeCollection(string name, CancellationToken cancellationToken = default) { return await Http .GetFromJsonAsync( $"/collections/{UrlEncoder.Default.Encode(name)}", - SerializerContext.Default.CollectionDetails) + SerializerContext.Default.CollectionDetails, + cancellationToken) .ConfigureAwait(false) ?? ThrowHelpers.JsonException(); } - public async Task DeleteCollection(string name) => - await (await Http.DeleteAsync($"/collections/{UrlEncoder.Default.Encode(name)}")) + public async Task DeleteCollection(string name, CancellationToken cancellationToken = default) => + await (await Http.DeleteAsync($"/collections/{UrlEncoder.Default.Encode(name)}", cancellationToken)) .CheckStatusCode() .ConfigureAwait(false); diff --git a/test/DataTests.cs b/test/DataTestBase.cs similarity index 54% rename from test/DataTests.cs rename to test/DataTestBase.cs index 5ddeca5..673d92e 100644 --- a/test/DataTests.cs +++ b/test/DataTestBase.cs @@ -1,15 +1,13 @@ using Pinecone; -using Pinecone.Grpc; using PineconeTests.Xunit; using Xunit; namespace PineconeTests; -[Collection("PineconeTests")] -[PineconeApiKeySetCondition] -public class DataTests(DataTests.TestFixture fixture) : IClassFixture +public abstract class DataTestBase(TFixture fixture) : IClassFixture + where TFixture: DataTestFixtureBase { - private TestFixture Fixture { get; } = fixture; + private TFixture Fixture { get; } = fixture; [PineconeFact] public async Task Basic_query() @@ -164,11 +162,11 @@ public async Task Basic_vector_upsert_update_delete() var attemptCount = 0; do { - await Task.Delay(TestFixture.DelayInterval); + await Task.Delay(DataTestFixtureBase.DelayInterval); attemptCount++; var finalFetch = await Fixture.Index.Fetch(["update-vector-id-2"], testNamespace); updatedVector = finalFetch["update-vector-id-2"]; - } while (updatedVector.Values[0] != 23 && attemptCount < TestFixture.MaxAttemptCount); + } while (updatedVector.Values[0] != 23 && attemptCount < DataTestFixtureBase.MaxAttemptCount); Assert.Equal("update-vector-id-2", updatedVector.Id); Assert.Equal([23, 3, 5, 7, 11, 13, 17, 19], updatedVector.Values); @@ -202,11 +200,11 @@ public async Task Upsert_on_existing_vector_makes_an_update() var attemptCount = 0; do { - await Task.Delay(TestFixture.DelayInterval); + await Task.Delay(DataTestFixtureBase.DelayInterval); attemptCount++; var finalFetch = await Fixture.Index.Fetch(["update-vector-id-3"], testNamespace); updatedVector = finalFetch["update-vector-id-3"]; - } while (updatedVector.Values[0] != 0 && attemptCount < TestFixture.MaxAttemptCount); + } while (updatedVector.Values[0] != 0 && attemptCount < DataTestFixtureBase.MaxAttemptCount); Assert.Equal("update-vector-id-3", updatedVector.Id); Assert.Equal([0, 1, 1, 2, 3, 5, 8, 13], updatedVector.Values); @@ -231,11 +229,11 @@ public async Task Delete_all_vectors_from_namespace() var attemptCount = 0; do { - await Task.Delay(TestFixture.DelayInterval); + await Task.Delay(DataTestFixtureBase.DelayInterval); attemptCount++; stats = await Fixture.Index.DescribeStats(); } while (stats.Namespaces.Where(x => x.Name == testNamespace).Select(x => x.VectorCount).SingleOrDefault() > 0 - && attemptCount <= TestFixture.MaxAttemptCount); + && attemptCount <= DataTestFixtureBase.MaxAttemptCount); Assert.Equal((uint)0, stats.Namespaces.Where(x => x.Name == testNamespace).Select(x => x.VectorCount).SingleOrDefault()); } @@ -245,180 +243,4 @@ public async Task Delete_vector_that_doesnt_exist() { await Fixture.Index.Delete(["non-existing-index"]); } - - public class TestFixture : IAsyncLifetime - { - // 10s with 100ms intervals - public const int MaxAttemptCount = 100; - public const int DelayInterval = 100; - private const string IndexName = "serverless-data-tests"; - - public PineconeClient Pinecone { get; private set; } = null!; - public Index Index { get; private set; } = null!; - - public async Task InitializeAsync() - { - Pinecone = new PineconeClient(UserSecretsExtensions.ReadPineconeApiKey()); - - await ClearIndexesAsync(); - await CreateIndexAndWait(); - await AddSampleDataAsync(); - } - - private async Task CreateIndexAndWait() - { - var attemptCount = 0; - await Pinecone.CreateServerlessIndex(IndexName, dimiension: 8, metric: Metric.Euclidean, cloud: "aws", region: "us-east-1"); - - do - { - await Task.Delay(DelayInterval); - attemptCount++; - Index = await Pinecone.GetIndex(IndexName); - } while (!Index.Status.IsReady && attemptCount <= MaxAttemptCount); - - if (!Index.Status.IsReady) - { - throw new InvalidOperationException("'Create index' operation didn't complete in time. Index name: " + IndexName); - } - } - - private async Task AddSampleDataAsync() - { - var basicVectors = Enumerable.Range(1, 5).Select(i => new Vector - { - Id = "basic-vector-" + i, - Values = [i * 0.5f, i * 1.0f, i * 1.5f, i * 2.0f, i * 2.5f, i * 3.0f, i * 3.5f, i * 4.0f], - }).ToList(); - - await InsertAndWait(basicVectors); - - var customNamespaceVectors = Enumerable.Range(1, 3).Select(i => new Vector - { - Id = "custom-namespace-vector-" + i, - Values = [i * 1.1f, i * 2.2f, i * 3.3f, i * 4.4f, i * 5.5f, i * 6.6f, i * 7.7f, i * 8.8f], - }).ToList(); - - await InsertAndWait(customNamespaceVectors, "namespace1"); - - var metadata1 = new MetadataMap - { - ["type"] = "number set", - ["subtype"] = "primes", - ["rank"] = 3, - ["overhyped"] = false, - ["list"] = new string[] { "2", "1" }, - }; - - var metadata2 = new MetadataMap - { - ["type"] = "number set", - ["subtype"] = "fibo", - ["list"] = new string[] { "0", "1" }, - }; - - var metadata3 = new MetadataMap - { - ["type"] = "number set", - ["subtype"] = "lucas", - ["rank"] = 12, - ["overhyped"] = false, - ["list"] = new string[] { "two", "one" }, - }; - - var metadataVectors = new Vector[] - { - new() { Id = "metadata-vector-1", Values = [2, 3, 5, 7, 11, 13, 17, 19], Metadata = metadata1 }, - new() { Id = "metadata-vector-2", Values = [0, 1, 1, 2, 3, 5, 8, 13], Metadata = metadata2 }, - new() { Id = "metadata-vector-3", Values = [2, 1, 3, 4, 7, 11, 18, 29], Metadata = metadata3 }, - }; - - await InsertAndWait(metadataVectors); - } - - public async Task InsertAndWait(IEnumerable vectors, string? indexNamespace = null) - { - // NOTE: this only works when inserting *new* vectors, if the vector already exisits the new vector count won't match - // and we will have false-negative "failure" to insert - var stats = await Index.DescribeStats(); - var vectorCountBefore = stats.TotalVectorCount; - var attemptCount = 0; - var result = await Index.Upsert(vectors, indexNamespace); - - do - { - await Task.Delay(DelayInterval); - attemptCount++; - stats = await Index.DescribeStats(); - } while (stats.TotalVectorCount < vectorCountBefore + vectors.Count() && attemptCount <= MaxAttemptCount); - - if (stats.TotalVectorCount < vectorCountBefore + vectors.Count()) - { - throw new InvalidOperationException("'Upsert' operation didn't complete in time. Vectors count: " + vectors.Count()); - } - - return result; - } - - public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace = null) - { - var stats = await Index.DescribeStats(); - var vectorCountBefore = stats.Namespaces.Single(x => x.Name == (indexNamespace ?? "")).VectorCount; - - var attemptCount = 0; - await Index.Delete(ids, indexNamespace); - long vectorCount; - do - { - await Task.Delay(DelayInterval); - attemptCount++; - stats = await Index.DescribeStats(); - vectorCount = stats.Namespaces.Single(x => x.Name == (indexNamespace ?? "")).VectorCount; - } while (vectorCount > vectorCountBefore - ids.Count() && attemptCount <= MaxAttemptCount); - - if (vectorCount > vectorCountBefore - ids.Count()) - { - throw new InvalidOperationException("'Delete' operation didn't complete in time."); - } - } - - public async Task DisposeAsync() - { - if (Pinecone is not null) - { - await ClearIndexesAsync(); - Pinecone.Dispose(); - } - } - - private async Task ClearIndexesAsync() - { - foreach (var existingIndex in await Pinecone.ListIndexes()) - { - await DeleteExistingIndexAndWaitAsync(existingIndex.Name); - } - } - - private async Task DeleteExistingIndexAndWaitAsync(string indexName) - { - var exists = true; - var attemptCount = 0; - await Pinecone.DeleteIndex(indexName); - - do - { - await Task.Delay(DelayInterval); - var indexes = (await Pinecone.ListIndexes()).Select(x => x.Name).ToArray(); - if (indexes.Length == 0 || !indexes.Contains(indexName)) - { - exists = false; - } - } while (exists && attemptCount <= MaxAttemptCount); - - if (exists) - { - throw new InvalidOperationException("'Delete index' operation didn't complete in time. Index name: " + indexName); - } - } - } } \ No newline at end of file diff --git a/test/DataTestFixtureBase.cs b/test/DataTestFixtureBase.cs new file mode 100644 index 0000000..215bf6d --- /dev/null +++ b/test/DataTestFixtureBase.cs @@ -0,0 +1,165 @@ +using Pinecone; +using Pinecone.Grpc; +using Xunit; + +namespace PineconeTests; +public abstract class DataTestFixtureBase : IAsyncLifetime +{ + public const int MaxAttemptCount = 100; + public const int DelayInterval = 300; + + protected abstract string IndexName { get; } + + public PineconeClient Pinecone { get; private set; } = null!; + + public virtual Index Index { get; set; } = null!; + + public virtual async Task InitializeAsync() + { + Pinecone = new PineconeClient(UserSecretsExtensions.ReadPineconeApiKey()); + + await ClearIndexesAsync(); + await CreateIndexAndWait(); + await AddSampleDataAsync(); + } + + protected abstract Task CreateIndexAndWait(); + + public async Task DisposeAsync() + { + if (Pinecone is not null) + { + await ClearIndexesAsync(); + Pinecone.Dispose(); + } + } + + private async Task AddSampleDataAsync() + { + var basicVectors = Enumerable.Range(1, 5).Select(i => new Vector + { + Id = "basic-vector-" + i, + Values = [i * 0.5f, i * 1.0f, i * 1.5f, i * 2.0f, i * 2.5f, i * 3.0f, i * 3.5f, i * 4.0f], + }).ToList(); + + await InsertAndWait(basicVectors); + + var customNamespaceVectors = Enumerable.Range(1, 3).Select(i => new Vector + { + Id = "custom-namespace-vector-" + i, + Values = [i * 1.1f, i * 2.2f, i * 3.3f, i * 4.4f, i * 5.5f, i * 6.6f, i * 7.7f, i * 8.8f], + }).ToList(); + + await InsertAndWait(customNamespaceVectors, "namespace1"); + + var metadata1 = new MetadataMap + { + ["type"] = "number set", + ["subtype"] = "primes", + ["rank"] = 3, + ["overhyped"] = false, + ["list"] = new string[] { "2", "1" }, + }; + + var metadata2 = new MetadataMap + { + ["type"] = "number set", + ["subtype"] = "fibo", + ["list"] = new string[] { "0", "1" }, + }; + + var metadata3 = new MetadataMap + { + ["type"] = "number set", + ["subtype"] = "lucas", + ["rank"] = 12, + ["overhyped"] = false, + ["list"] = new string[] { "two", "one" }, + }; + + var metadataVectors = new Vector[] + { + new() { Id = "metadata-vector-1", Values = [2, 3, 5, 7, 11, 13, 17, 19], Metadata = metadata1 }, + new() { Id = "metadata-vector-2", Values = [0, 1, 1, 2, 3, 5, 8, 13], Metadata = metadata2 }, + new() { Id = "metadata-vector-3", Values = [2, 1, 3, 4, 7, 11, 18, 29], Metadata = metadata3 }, + }; + + await InsertAndWait(metadataVectors); + } + + public virtual async Task InsertAndWait(IEnumerable vectors, string? indexNamespace = null) + { + // NOTE: this only works when inserting *new* vectors, if the vector already exisits the new vector count won't match + // and we will have false-negative "failure" to insert + var stats = await Index.DescribeStats(); + var vectorCountBefore = stats.TotalVectorCount; + var attemptCount = 0; + var result = await Index.Upsert(vectors, indexNamespace); + + do + { + await Task.Delay(DelayInterval); + attemptCount++; + stats = await Index.DescribeStats(); + } while (stats.TotalVectorCount < vectorCountBefore + vectors.Count() && attemptCount <= MaxAttemptCount); + + if (stats.TotalVectorCount < vectorCountBefore + vectors.Count()) + { + throw new InvalidOperationException("'Upsert' operation didn't complete in time. Vectors count: " + vectors.Count()); + } + + return result; + } + + public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace = null) + { + var stats = await Index.DescribeStats(); + var vectorCountBefore = stats.Namespaces.Single(x => x.Name == (indexNamespace ?? "")).VectorCount; + + var attemptCount = 0; + await Index.Delete(ids, indexNamespace); + long vectorCount; + do + { + await Task.Delay(DelayInterval); + attemptCount++; + stats = await Index.DescribeStats(); + vectorCount = stats.Namespaces.Single(x => x.Name == (indexNamespace ?? "")).VectorCount; + } while (vectorCount > vectorCountBefore - ids.Count() && attemptCount <= MaxAttemptCount); + + if (vectorCount > vectorCountBefore - ids.Count()) + { + throw new InvalidOperationException("'Delete' operation didn't complete in time."); + } + } + + private async Task ClearIndexesAsync() + { + foreach (var existingIndex in await Pinecone.ListIndexes()) + { + await DeleteExistingIndexAndWaitAsync(existingIndex.Name); + } + } + + private async Task DeleteExistingIndexAndWaitAsync(string indexName) + { + var exists = true; + var attemptCount = 0; + await Pinecone.DeleteIndex(indexName); + + do + { + await Task.Delay(DelayInterval); + var indexes = (await Pinecone.ListIndexes()).Select(x => x.Name).ToArray(); + if (indexes.Length == 0 || !indexes.Contains(indexName)) + { + exists = false; + } + } while (exists && attemptCount <= MaxAttemptCount); + + if (exists) + { + throw new InvalidOperationException("'Delete index' operation didn't complete in time. Index name: " + indexName); + } + } +} diff --git a/test/IndexTests.cs b/test/IndexTests.cs index c3ec14b..0a24ac3 100644 --- a/test/IndexTests.cs +++ b/test/IndexTests.cs @@ -12,22 +12,6 @@ public class IndexTests private const int MaxAttemptCount = 300; private const int DelayInterval = 100; - private async Task DeleteIndexAndWait(PineconeClient pinecone, string indexName) - { - await pinecone.DeleteIndex(indexName); - - List existingIndexes; - var attemptCount = 0; - // wait until old index has been deleted - do - { - await Task.Delay(DelayInterval); - attemptCount++; - existingIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); - } - while (existingIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); - } - [PineconeTheory] [InlineData(Metric.DotProduct, true)] [InlineData(Metric.Cosine, true)] @@ -50,10 +34,11 @@ public async Task Create_and_delete_index(Metric metric, bool serverless) //await pinecone.DeleteIndex(indexName); } - // if we create pod-based index, we need to create any previous gcp-starter indexes + // if we create pod-based index, we need to delete any previous gcp-starter indexes // only one pod-based index is allowed on the starter environment if (!serverless) { + existingIndexes = await pinecone.ListIndexes(); foreach (var existingPodBasedIndex in existingIndexes.Where(x => x.Spec.Pod?.Environment == "gcp-starter")) { await DeleteIndexAndWait(pinecone, existingPodBasedIndex.Name); @@ -90,6 +75,22 @@ public async Task Create_and_delete_index(Metric metric, bool serverless) await pinecone.DeleteIndex(indexName); } + private async Task DeleteIndexAndWait(PineconeClient pinecone, string indexName) + { + await pinecone.DeleteIndex(indexName); + + List existingIndexes; + var attemptCount = 0; + // wait until old index has been deleted + do + { + await Task.Delay(DelayInterval); + attemptCount++; + existingIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); + } + while (existingIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); + } + [PineconeFact] public async Task List_collections() { diff --git a/test/PodBasedDataTests.cs b/test/PodBasedDataTests.cs new file mode 100644 index 0000000..fdb1d03 --- /dev/null +++ b/test/PodBasedDataTests.cs @@ -0,0 +1,33 @@ +using Pinecone; +using PineconeTests.Xunit; +using Xunit; + +namespace PineconeTests; + +[Collection("PineconeTests")] +[PineconeApiKeySetCondition] +public class PodBasedDataTests(PodBasedDataTests.PodBasedDataTestFixture fixture) : DataTestBase(fixture) +{ + public class PodBasedDataTestFixture : DataTestFixtureBase + { + protected override string IndexName => "pod-data-tests"; + + protected override async Task CreateIndexAndWait() + { + var attemptCount = 0; + await Pinecone.CreatePodBasedIndex(IndexName, dimension: 8, metric: Metric.Cosine, environment: "gcp-starter", podType: "starter", pods: 1); + + do + { + await Task.Delay(DelayInterval); + attemptCount++; + Index = await Pinecone.GetIndex(IndexName); + } while (!Index.Status.IsReady && attemptCount <= MaxAttemptCount); + + if (!Index.Status.IsReady) + { + throw new InvalidOperationException("'Create index' operation didn't complete in time. Index name: " + IndexName); + } + } + } +} diff --git a/test/ServerlessDataTests.cs b/test/ServerlessDataTests.cs new file mode 100644 index 0000000..1da0153 --- /dev/null +++ b/test/ServerlessDataTests.cs @@ -0,0 +1,33 @@ +using Pinecone; +using PineconeTests.Xunit; +using Xunit; + +namespace PineconeTests; + +[Collection("PineconeTests")] +[PineconeApiKeySetCondition] +public class ServerlessDataTests(ServerlessDataTests.ServerlessDataTestFixture fixture) : DataTestBase(fixture) +{ + public class ServerlessDataTestFixture : DataTestFixtureBase + { + protected override string IndexName => "serverless-data-tests"; + + protected override async Task CreateIndexAndWait() + { + var attemptCount = 0; + await Pinecone.CreateServerlessIndex(IndexName, dimension: 8, metric: Metric.Cosine, cloud: "aws", region: "us-east-1"); + + do + { + await Task.Delay(DelayInterval); + attemptCount++; + Index = await Pinecone.GetIndex(IndexName); + } while (!Index.Status.IsReady && attemptCount <= MaxAttemptCount); + + if (!Index.Status.IsReady) + { + throw new InvalidOperationException("'Create index' operation didn't complete in time. Index name: " + IndexName); + } + } + } +} From bfb6b33bcb2dae90191fa0329041f65b194cdd34 Mon Sep 17 00:00:00 2001 From: maumar Date: Fri, 17 May 2024 02:13:03 -0700 Subject: [PATCH 05/15] adding documentation, adding tests for sparse vectors, small API fixes, reacting to initial CR feedback --- src/Index.cs | 100 +++++++++++++++++++++++ src/PineconeClient.cs | 110 ++++++++++++++++++++++++- src/Rest/Types.cs | 5 ++ src/Types/IndexTypes.cs | 147 ++++++++++++++++++++++++++++++++-- src/Types/VectorTypes.cs | 67 ++++++++++++++++ test/DataTestBase.cs | 20 ++++- test/DataTestFixtureBase.cs | 14 +++- test/IndexTests.cs | 2 +- test/PodBasedDataTests.cs | 2 +- test/ServerlessDataTests.cs | 2 +- test/UserSecretsExtensions.cs | 13 ++- 11 files changed, 460 insertions(+), 22 deletions(-) diff --git a/src/Index.cs b/src/Index.cs index 59e77af..be1c70e 100644 --- a/src/Index.cs +++ b/src/Index.cs @@ -10,26 +10,75 @@ public sealed partial record Index< #endif TTransport> where TTransport : ITransport { + /// + /// Name of the index. + /// public required string Name { get; init; } + + /// + /// The dimension of the vectors stored in the index. + /// public required uint Dimension { get; init; } + + /// + /// The distance metric to be used for similarity search. + /// public required Metric Metric { get; init; } + + /// + /// The URL address where the index is hosted. + /// public string? Host { get; init; } + + /// + /// Additional information about the index. + /// public required IndexSpec Spec { get; init; } + + /// + /// The current status of the index. + /// public required IndexStatus Status { get; init; } } // Implementation + +/// +/// An object used for interacting with vectors. It is used to upsert, query, fetch, update, delete and list vectors, as well as retrieving index statistics. +/// +/// The type of transport layer used. public sealed partial record Index : IDisposable where TTransport : ITransport { + /// + /// The transport layer. + /// [JsonIgnore] internal TTransport Transport { get; set; } = default!; + /// + /// Returns statistics describing the contents of an index, including the vector count per namespace and the number of dimensions, and the index fullness. + /// + /// The operation only returns statistics for vectors that satisfy the filter. + /// An object containing index statistics. public Task DescribeStats(MetadataMap? filter = null) { return Transport.DescribeStats(filter); } + /// + /// Searches an index using the values of a vector with specified ID. It retrieves the IDs of the most similar items, along with their similarity scores. + /// + /// Query by ID uses Approximate Nearest Neighbor, which doesn't guarantee the input vector to appear in the results. To ensure that, use the Fetch operation instead. + /// The unique ID of the vector to be used as a query vector. + /// The query vector. This should be the same length as the dimension of the index being queried. + /// Vector sparse data. Represented as a list of indices and a list of corresponded values, which must be with the same length. + /// The number of results to return for each query. + /// The filter to apply. + /// Namespace to query from. If no namespace is provided, the operation applies to all namespaces. + /// Indicates whether vector values are included in the response. + /// Indicates whether metadata is included in the response as well as the IDs. + /// public Task Query( string id, uint topK, @@ -49,6 +98,17 @@ public Task Query( includeMetadata: includeMetadata); } + /// + /// Searches an index using the specified vector values. It retrieves the IDs of the most similar items, along with their similarity scores. + /// + /// The query vector. This should be the same length as the dimension of the index being queried. + /// Vector sparse data. Represented as a list of indices and a list of corresponded values, which must be with the same length. + /// The number of results to return for each query. + /// The filter to apply. + /// Namespace to query from. If no namespace is provided, the operation applies to all namespaces. + /// Indicates whether vector values are included in the response. + /// Indicates whether metadata is included in the response as well as the IDs. + /// public Task Query( float[] values, uint topK, @@ -69,16 +129,35 @@ public Task Query( includeMetadata: includeMetadata); } + /// + /// Writes vector into the index. If a new value is provided for an existing vector ID, it will overwrite the previous value. + /// + /// A collection of objects to upsert. + /// Namespace to write the vector to. If no namespace is provided, the operation applies to all namespaces. + /// The number of vectors upserted. public Task Upsert(IEnumerable vectors, string? indexNamespace = null) { return Transport.Upsert(vectors, indexNamespace); } + /// + /// Updates a vector using the object. + /// + /// object containing updated information. + /// Namespace to update the vector from. If no namespace is provided, the operation applies to all namespaces. public Task Update(Vector vector, string? indexNamespace = null) { return Transport.Update(vector, indexNamespace); } + /// + /// Updates a vector. + /// + /// The ID of the vector to update. + /// New vector values. + /// New vector sparse data. + /// New vector metadata. + /// Namespace to update the vector from. If no namespace is provided, the operation applies to all namespaces. public Task Update( string id, float[]? values = null, @@ -89,25 +168,46 @@ public Task Update( return Transport.Update(id, values, sparseValues, metadata, indexNamespace); } + /// + /// Looks up and returns vectors, by ID. The returned vectors include the vector data and/or metadata. + /// + /// IDs of vectors to fetch. + /// Namespace to fetch vectors from. If no namespace is provided, the operation applies to all namespaces. + /// A dictionary containing vector IDs and the corresponding objects containing the vector information. public Task> Fetch(IEnumerable ids, string? indexNamespace = null) { return Transport.Fetch(ids, indexNamespace); } + /// + /// Deletes vectors with specified ids. + /// + /// + /// Namespace to delete vectors from. If no namespace is provided, the operation applies to all namespaces. public Task Delete(IEnumerable ids, string? indexNamespace = null) { return Transport.Delete(ids, indexNamespace); } + /// + /// Deletes vectors based on metadata filter provided. + /// + /// Filter used to select vectors to delete. + /// Namespace to delete vectors from. If no namespace is provided, the operation applies to all namespaces. public Task Delete(MetadataMap filter, string? indexNamespace = null) { return Transport.Delete(filter, indexNamespace); } + /// + /// Deletes all vectors. + /// + /// Namespace to delete vectors from. If no namespace is provided, the operation applies to all namespaces. public Task DeleteAll(string? indexNamespace = null) { return Transport.DeleteAll(indexNamespace); } + /// public void Dispose() => Transport.Dispose(); } diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index c11e852..5c6044b 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -7,15 +7,27 @@ namespace Pinecone; +/// +/// Main entry point for interacting with Pinecone. It is used to create, delete and modify indexes. +/// public sealed class PineconeClient : IDisposable { private readonly HttpClient Http; - + + /// + /// Creates a new instance of the class. + /// + /// API key used to connect to Pinecone. public PineconeClient(string apiKey) : this(apiKey, new Uri($"https://api.pinecone.io")) { } + /// + /// Creates a new instance of the class. + /// + /// API key used to connect to Pinecone. + /// Url used to connect to Pinecone. public PineconeClient(string apiKey, Uri baseUrl) { Guard.IsNotNullOrWhiteSpace(apiKey); @@ -25,6 +37,11 @@ public PineconeClient(string apiKey, Uri baseUrl) Http.DefaultRequestHeaders.Add("Api-Key", apiKey); } + /// + /// Creates a new instance of the class. + /// + /// API key used to connect to Pinecone. + /// HTTP client used to connect to Pinecone. public PineconeClient(string apiKey, HttpClient client) { Guard.IsNotNullOrWhiteSpace(apiKey); @@ -34,6 +51,11 @@ public PineconeClient(string apiKey, HttpClient client) Http.DefaultRequestHeaders.Add("Api-Key", apiKey); } + /// + /// Returns a list of indexes in the project. + /// + /// A to observe while waiting for the task to complete. + /// List of index descriptions for all indexes in the project. public async Task ListIndexes(CancellationToken cancellationToken = default) { var listIndexesResult = (ListIndexesResult?)await Http @@ -43,15 +65,47 @@ public async Task ListIndexes(CancellationToken cancellationToke return listIndexesResult?.Indexes ?? []; } - public Task CreatePodBasedIndex(string name, uint dimension, Metric metric, string environment, string podType, long pods, CancellationToken cancellationToken = default) + /// + /// Creates a pod-based index. Pod-based indexes use pre-configured units of hardware. + /// + /// Name of the index. + /// The dimension of vectors stored in the index. + /// The distance metric used for similarity search. + /// The environment where the index is hosted. For free starter plan set the environment as "gcp-starter". + /// The type of pod to use. A string containing one of "s1", "p1", or "p2" appended with "." and one of "x1", "x2", "x4", or "x8". + /// Number of pods to use. This should be equal to number of shards multiplied by the number of replicas. + /// Number of shards to split the data across multiple pods. + /// Number of replicas. Replicas duplicate the index for greater availability and throughput. + /// A to observe while waiting for the task to complete. + /// + public Task CreatePodBasedIndex( + string name, + uint dimension, + Metric metric, + string environment, + string podType = "p1.x1", + long? pods = 1, + long? shards = 1, + long? replicas = 1, + CancellationToken cancellationToken = default) => CreateIndexAsync(new CreateIndexRequest { Name = name, Dimension = dimension, Metric = metric, - Spec = new IndexSpec { Pod = new PodSpec { Environment = environment, PodType = podType, Pods = pods } } + Spec = new IndexSpec { Pod = new PodSpec { Environment = environment, PodType = podType, Pods = pods, Replicas = replicas, Shards = shards } } }, cancellationToken); + /// + /// Creates a serverless index. Serverless indexes scale dynamically based on usage. + /// + /// Name of the index. + /// The dimension of vectors stored in the index. + /// The distance metric used for similarity search. + /// The public cloud where the index will be hosted. + /// The region where the index will be created. + /// A to observe while waiting for the task to complete. + /// public Task CreateServerlessIndex(string name, uint dimension, Metric metric, string cloud, string region, CancellationToken cancellationToken = default) => CreateIndexAsync(new CreateIndexRequest { @@ -70,8 +124,23 @@ private async Task CreateIndexAsync(CreateIndexRequest request, CancellationToke await response.CheckStatusCode().ConfigureAwait(false); } + /// + /// Creates an object describing the index. It is a main entry point for interacting with vectors. + /// It is used to upsert, query, fetch, update, delete and list vectors, as well as retrieving index statistics. + /// + /// Name of the index to describe. + /// A to observe while waiting for the task to complete. + /// describing the index. public Task> GetIndex(string name, CancellationToken cancellationToken = default) => GetIndex(name, cancellationToken); + /// + /// Creates an object describing the index. It is a main entry point for interacting with vectors. + /// It is used to upsert, query, fetch, update, delete and list vectors, as well as retrieving index statistics. + /// + /// The type of transport layer used, either or . + /// Name of the index to describe. + /// A to observe while waiting for the task to complete. + /// describing the index. #if NET7_0_OR_GREATER public async Task> GetIndex(string name, CancellationToken cancellationToken = default) #else @@ -112,6 +181,13 @@ public async Task> GetIndex< return index; } + /// + /// Specifies the pod type and number of replicas for an index. It applies to pod-based indexes only. Serverless indexes scale automatically based on usage. + /// + /// Name of the pod-based index to configure. + /// The new number or replicas. + /// The new pod type. + /// A to observe while waiting for the task to complete. public async Task ConfigureIndex(string name, int? replicas = null, string? podType = null, CancellationToken cancellationToken = default) { if (replicas is null && podType is null or []) @@ -132,11 +208,21 @@ public async Task ConfigureIndex(string name, int? replicas = null, string? podT await response.CheckStatusCode().ConfigureAwait(false); } + /// + /// Deletes an existing index. + /// + /// Name of index to delete. + /// A to observe while waiting for the task to complete. public async Task DeleteIndex(string name, CancellationToken cancellationToken = default) => await (await Http.DeleteAsync($"/indexes/{UrlEncoder.Default.Encode(name)}", cancellationToken).ConfigureAwait(false)) .CheckStatusCode() .ConfigureAwait(false); + /// + /// Returns a list of collections in the project. + /// + /// A to observe while waiting for the task to complete. + /// List of collection descriptions for all collections in the project. public async Task ListCollections(CancellationToken cancellationToken = default) { var listCollectionsResult = (ListCollectionsResult?)await Http @@ -148,6 +234,12 @@ public async Task ListCollections(CancellationToken cancell return listCollectionsResult?.Collections ?? []; } + /// + /// Creates a new collection based on the source index. + /// + /// Name of the collection to create. + /// The name of the index to be used as the source for the collection. + /// A to observe while waiting for the task to complete. public async Task CreateCollection(string name, string source, CancellationToken cancellationToken = default) { var request = new CreateCollectionRequest { Name = name, Source = source }; @@ -159,6 +251,12 @@ public async Task CreateCollection(string name, string source, CancellationToken await response.CheckStatusCode().ConfigureAwait(false); } + /// + /// Gets a description of a collection. + /// + /// Name of the collection to describe. + /// A to observe while waiting for the task to complete. + /// A describing the collection. public async Task DescribeCollection(string name, CancellationToken cancellationToken = default) { return await Http @@ -169,10 +267,16 @@ public async Task DescribeCollection(string name, Cancellatio .ConfigureAwait(false) ?? ThrowHelpers.JsonException(); } + /// + /// Deletes an existing collection. + /// + /// Name of the collection to delete. + /// A to observe while waiting for the task to complete. public async Task DeleteCollection(string name, CancellationToken cancellationToken = default) => await (await Http.DeleteAsync($"/collections/{UrlEncoder.Default.Encode(name)}", cancellationToken)) .CheckStatusCode() .ConfigureAwait(false); + /// public void Dispose() => Http.Dispose(); } diff --git a/src/Rest/Types.cs b/src/Rest/Types.cs index 1a3e431..4d3c094 100644 --- a/src/Rest/Types.cs +++ b/src/Rest/Types.cs @@ -2,6 +2,11 @@ namespace Pinecone.Rest; +internal sealed record ListIndexesResult +{ + public required IndexDetails[] Indexes { get; init; } +} + internal sealed record CreateIndexRequest { public required string Name { get; init; } diff --git a/src/Types/IndexTypes.cs b/src/Types/IndexTypes.cs index 42602ae..376e136 100644 --- a/src/Types/IndexTypes.cs +++ b/src/Types/IndexTypes.cs @@ -3,37 +3,84 @@ namespace Pinecone; -public record ListIndexesResult -{ - public required IndexDetails[] Indexes { get; init; } -} - +/// +/// Object storing information about the index. +/// public record IndexDetails { + /// + /// Name of the index. + /// public required string Name { get; init; } + + /// + /// The dimension of the indexed vectors. + /// public required uint Dimension { get; init; } + + /// + /// The distance metric used for similarity search. + /// public required Metric Metric { get; init; } + + /// + /// The URL address where the index is hosted. + /// public string? Host { get; init; } + /// + /// Additional information about the index. + /// public required IndexSpec Spec { get; init;} + + /// + /// The current status of the index. + /// public required IndexStatus Status { get; init; } } +/// +/// The distance metric used for similarity search. +/// [JsonConverter(typeof(JsonStringEnumConverter))] public enum Metric { + /// + /// A measure of the angle between two vectors. It is computed by taking the dot product of the vectors and dividing it by the product of their magnitudes. + /// [JsonPropertyName("cosine")] Cosine = 0, + + /// + /// Calculated by adding the products of the vectors' corresponding components. + /// [JsonPropertyName("dotproduct")] DotProduct = 1, + + /// + /// Straight-line distance between two vectors in a multidimensional space. + /// [JsonPropertyName("euclidean")] Euclidean = 2 } +/// +/// Current status of the index. +/// public record IndexStatus { + /// + /// A value indicating whether the index is ready. + /// [JsonPropertyName("ready")] public required bool IsReady { get; init; } + + /// + /// Current state of the index. + /// public required IndexState State { get; init; } } +/// +/// Current state of the index. +/// [JsonConverter(typeof(JsonStringEnumConverter))] public enum IndexState { @@ -47,46 +94,130 @@ public enum IndexState InitializationFailed = 7 } +/// +/// Index specification. +/// public record IndexSpec { + /// + /// Serverless index specification. if the index is pod-based. + /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ServerlessSpec? Serverless { get; init; } - + + /// + /// Pod-based index specification. if the index is serverless. + /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public PodSpec? Pod { get; init; } } +/// +/// Serverless index specification. +/// public record ServerlessSpec { + /// + /// The public cloud where the index is hosted. + /// public required string Cloud { get; init; } + + /// + /// The region where the index has been created. + /// public required string Region { get; init; } } +/// +/// Pod-based index specification. +/// public record PodSpec { + /// + /// The environment where the index is hosted. + /// [JsonPropertyName("environment")] public required string Environment { get; init; } - public long? Replicas { get; init; } + + /// + /// The pod type. + /// [JsonPropertyName("pod_type")] public required string PodType { get; init; } - public long Pods { get; init; } + + /// + /// The number of pods used. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Pods { get; init; } + + /// + /// The number od replicas. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Replicas { get; init; } + + /// + /// The number of shards. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Shards { get; init; } + + /// + /// Configuration for the behavior of internal metadata index. By default, all metadata is indexed. + /// When MetadataConfig is present, only specified metadata fields are indexed. + /// [JsonPropertyName("metadata_config")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public MetadataMap? MetadataConfig { get; init; } + + /// + /// The name of the collection used as the source for the index. + /// [JsonPropertyName("source_collection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SourceCollection { get; init; } } +/// +/// Statistics describing the contents of an index. +/// public record IndexStats { + /// + /// List of namespaces. + /// [JsonConverter(typeof(IndexNamespaceArrayConverter))] public required IndexNamespace[] Namespaces { get; init; } + + /// + /// The dimension of the indexed vectors. + /// public required uint Dimension { get; init; } + + /// + /// The fullness of the index, regardless of whether a metadata filter expression was passed. + /// public required float IndexFullness { get; init; } + + /// + /// Total number of vectors stored in the index. + /// public required uint TotalVectorCount { get; init; } } +/// +/// Information about a single namespace. +/// public readonly record struct IndexNamespace { + /// + /// Namespace name. + /// public required string Name { get; init; } + + /// + /// Number of vectors stored in the namespace. + /// public required uint VectorCount { get; init; } } diff --git a/src/Types/VectorTypes.cs b/src/Types/VectorTypes.cs index dbeabac..2ea1f08 100644 --- a/src/Types/VectorTypes.cs +++ b/src/Types/VectorTypes.cs @@ -4,38 +4,105 @@ namespace Pinecone; +/// +/// An object representing a vector. +/// public record Vector { + /// + /// Unique ID of the vector. + /// public required string Id { get; init; } + + /// + /// Vector data. + /// public required float[] Values { get; init; } + + /// + /// Sparse vector information. + /// public SparseVector? SparseValues { get; init; } + + /// + /// Metadata associated with this vector. + /// public MetadataMap? Metadata { get; init; } } +/// +/// Contains sparse vector information. +/// public readonly record struct SparseVector { + /// + /// The indices of the sparse data. + /// public required uint[] Indices { get; init; } + + /// + /// The corresponding values of the sparse data, which must be with the same length as the indices. + /// public required float[] Values { get; init; } } +/// +/// Vector returned as a result of a query operation. Contains regular vector information as well as similarity score. +/// public record ScoredVector { + /// + /// Unique ID of the vector. + /// public required string Id { get; init; } + + /// + /// This is a measure of similarity between this vector and the query vector. The higher the score, the more they are similar. + /// public required double Score { get; init; } + + /// + /// Vector data. + /// public float[]? Values { get; init; } + + /// + /// Sparse vector information. + /// public SparseVector? SparseValues { get; init; } + + /// + /// Metadata associated with this vector. + /// public MetadataMap? Metadata { get; init; } } +/// +/// Collection of metadata consisting of key-value-pairs of property names and their corresponding values. +/// public sealed class MetadataMap : Dictionary { + /// + /// Creates a new instance of the class. + /// public MetadataMap() : base() { } + + /// + /// Creates a new instance of the class from an existing collection. + /// + /// public MetadataMap(IEnumerable> collection) : base(collection) { } } +/// +/// Value corresponding to a metadata property. +/// [JsonConverter(typeof(MetadataValueConverter))] public readonly record struct MetadataValue { + /// + /// Metadata value stored. + /// public object? Inner { get; } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/test/DataTestBase.cs b/test/DataTestBase.cs index 673d92e..05b36e0 100644 --- a/test/DataTestBase.cs +++ b/test/DataTestBase.cs @@ -16,9 +16,9 @@ public async Task Basic_query() var results = await Fixture.Index.Query( [x * 0.1f, x * 0.2f, x * 0.3f, x * 0.4f, x * 0.5f, x * 0.6f, x * 0.7f, x * 0.8f], - topK: 10); + topK: 20); - Assert.Equal(8, results.Length); + Assert.Equal(10, results.Length); results = await Fixture.Index.Query( @@ -140,6 +140,22 @@ public async Task Basic_fetch() Assert.Equal([1.5f, 3.0f, 4.5f, 6.0f, 7.5f, 9.0f, 10.5f, 12.0f], orderedResults[1].Value.Values); } + [PineconeFact] + public async Task Fetch_sparse_vector() + { + var results = await Fixture.Index.Fetch(["sparse-1"]); + + Assert.Single(results); + Assert.True(results.ContainsKey("sparse-1")); + var resultVector = results["sparse-1"]; + + Assert.Equal("sparse-1", resultVector.Id); + Assert.Equal([5, 10, 15, 20, 25, 30, 35, 40], resultVector.Values); + Assert.NotNull(resultVector.SparseValues); + Assert.Equal([1, 4], resultVector.SparseValues.Value.Indices); + Assert.Equal([0.2f, 0.5f], resultVector.SparseValues.Value.Values); + } + [PineconeFact] public async Task Basic_vector_upsert_update_delete() { diff --git a/test/DataTestFixtureBase.cs b/test/DataTestFixtureBase.cs index 215bf6d..4a59346 100644 --- a/test/DataTestFixtureBase.cs +++ b/test/DataTestFixtureBase.cs @@ -79,12 +79,20 @@ private async Task AddSampleDataAsync() var metadataVectors = new Vector[] { - new() { Id = "metadata-vector-1", Values = [2, 3, 5, 7, 11, 13, 17, 19], Metadata = metadata1 }, - new() { Id = "metadata-vector-2", Values = [0, 1, 1, 2, 3, 5, 8, 13], Metadata = metadata2 }, - new() { Id = "metadata-vector-3", Values = [2, 1, 3, 4, 7, 11, 18, 29], Metadata = metadata3 }, + new() { Id = "metadata-vector-1", Values = [2, 3, 5, 7, 11, 13, 17, 19], Metadata = metadata1 }, + new() { Id = "metadata-vector-2", Values = [0, 1, 1, 2, 3, 5, 8, 13], Metadata = metadata2 }, + new() { Id = "metadata-vector-3", Values = [2, 1, 3, 4, 7, 11, 18, 29], Metadata = metadata3 }, }; await InsertAndWait(metadataVectors); + + var sparseVectors = new Vector[] + { + new() { Id = "sparse-1", Values = [5, 10, 15, 20, 25, 30, 35, 40], SparseValues = new() { Indices = [1, 4], Values = [0.2f, 0.5f] } }, + new() { Id = "sparse-2", Values = [15, 110, 115, 120, 125, 130, 135, 140], SparseValues = new() { Indices = [2, 3], Values = [0.5f, 0.8f] } }, + }; + + await InsertAndWait(sparseVectors); } public virtual async Task InsertAndWait(IEnumerable vectors, string? indexNamespace = null) diff --git a/test/IndexTests.cs b/test/IndexTests.cs index 0a24ac3..a7f81ae 100644 --- a/test/IndexTests.cs +++ b/test/IndexTests.cs @@ -51,7 +51,7 @@ public async Task Create_and_delete_index(Metric metric, bool serverless) } else { - await pinecone.CreatePodBasedIndex(indexName, 3, metric, "gcp-starter", "starter", 1); + await pinecone.CreatePodBasedIndex(indexName, 3, metric, "gcp-starter"); } Index index; diff --git a/test/PodBasedDataTests.cs b/test/PodBasedDataTests.cs index fdb1d03..6cd99db 100644 --- a/test/PodBasedDataTests.cs +++ b/test/PodBasedDataTests.cs @@ -15,7 +15,7 @@ public class PodBasedDataTestFixture : DataTestFixtureBase protected override async Task CreateIndexAndWait() { var attemptCount = 0; - await Pinecone.CreatePodBasedIndex(IndexName, dimension: 8, metric: Metric.Cosine, environment: "gcp-starter", podType: "starter", pods: 1); + await Pinecone.CreatePodBasedIndex(IndexName, dimension: 8, metric: Metric.DotProduct, environment: "gcp-starter"); do { diff --git a/test/ServerlessDataTests.cs b/test/ServerlessDataTests.cs index 1da0153..ce410a5 100644 --- a/test/ServerlessDataTests.cs +++ b/test/ServerlessDataTests.cs @@ -15,7 +15,7 @@ public class ServerlessDataTestFixture : DataTestFixtureBase protected override async Task CreateIndexAndWait() { var attemptCount = 0; - await Pinecone.CreateServerlessIndex(IndexName, dimension: 8, metric: Metric.Cosine, cloud: "aws", region: "us-east-1"); + await Pinecone.CreateServerlessIndex(IndexName, dimension: 8, metric: Metric.DotProduct, cloud: "aws", region: "us-east-1"); do { diff --git a/test/UserSecretsExtensions.cs b/test/UserSecretsExtensions.cs index 4370d65..477cc53 100644 --- a/test/UserSecretsExtensions.cs +++ b/test/UserSecretsExtensions.cs @@ -15,8 +15,15 @@ public static string ReadPineconeApiKey() .UserSecretsId)))![PineconeApiKeyUserSecretEntry]; public static bool ContainsPineconeApiKey() - => JsonSerializer.Deserialize>( + { + var userSecretsIdAttribute = typeof(UserSecretsExtensions).Assembly.GetCustomAttribute(); + if (userSecretsIdAttribute == null) + { + return false; + } + + return JsonSerializer.Deserialize>( File.ReadAllText(PathHelper.GetSecretsPathFromSecretsId( - typeof(UserSecretsExtensions).Assembly.GetCustomAttribute()! - .UserSecretsId)))!.ContainsKey(PineconeApiKeyUserSecretEntry); + userSecretsIdAttribute.UserSecretsId)))!.ContainsKey(PineconeApiKeyUserSecretEntry); + } } \ No newline at end of file From 366bd82c2e76ddd198c221547424239dec9dd7a5 Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 15:14:08 +0300 Subject: [PATCH 06/15] ci: add missing 7.0 SDK --- .github/workflows/dotnet-releaser.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dotnet-releaser.yaml b/.github/workflows/dotnet-releaser.yaml index e351a51..7a6b24c 100644 --- a/.github/workflows/dotnet-releaser.yaml +++ b/.github/workflows/dotnet-releaser.yaml @@ -19,6 +19,7 @@ jobs: uses: actions/setup-dotnet@v2 with: dotnet-version: | + 6.0.x 7.0.x 8.0.x - name: CI/CD From fb00668f162508abfaecfd7853446ca71f303102 Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 15:22:23 +0300 Subject: [PATCH 07/15] fix remaining warnings/errors --- example/Example.FSharp/Program.fs | 5 +++-- test/PineconeTests.csproj | 1 - test/UserSecretsExtensions.cs | 9 +++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/example/Example.FSharp/Program.fs b/example/Example.FSharp/Program.fs index 4444428..fc96476 100644 --- a/example/Example.FSharp/Program.fs +++ b/example/Example.FSharp/Program.fs @@ -1,4 +1,5 @@ -open Pinecone +#nowarn "3391" +open Pinecone open System.Collections.Generic let createMetadata x = @@ -14,7 +15,7 @@ let main = task { let! indexList = pinecone.ListIndexes() if not (indexList |> Array.exists (fun index -> index.Name = indexName)) then // free serverless indexes are currently only available on AWS us-east-1 - pinecone.CreateServerlessIndex(indexName, 1536u, Metric.Cosine, "aws", "us-east-1") + do! pinecone.CreateServerlessIndex(indexName, 1536u, Metric.Cosine, "aws", "us-east-1") // Get the Pinecone index by name (uses gRPC by default). // The index client is thread-safe, consider caching and/or diff --git a/test/PineconeTests.csproj b/test/PineconeTests.csproj index 9ca4dab..58f4621 100644 --- a/test/PineconeTests.csproj +++ b/test/PineconeTests.csproj @@ -3,7 +3,6 @@ net6.0;net7.0;net8.0 nullable - true enable 12 enable diff --git a/test/UserSecretsExtensions.cs b/test/UserSecretsExtensions.cs index 477cc53..3df784f 100644 --- a/test/UserSecretsExtensions.cs +++ b/test/UserSecretsExtensions.cs @@ -22,8 +22,13 @@ public static bool ContainsPineconeApiKey() return false; } + var path = PathHelper.GetSecretsPathFromSecretsId(userSecretsIdAttribute.UserSecretsId); + if (!File.Exists(path)) + { + return false; + } + return JsonSerializer.Deserialize>( - File.ReadAllText(PathHelper.GetSecretsPathFromSecretsId( - userSecretsIdAttribute.UserSecretsId)))!.ContainsKey(PineconeApiKeyUserSecretEntry); + File.ReadAllText(path))!.ContainsKey(PineconeApiKeyUserSecretEntry); } } \ No newline at end of file From 4364fa2cbefa31ebbb33c549ebb42ff9172f2df0 Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 15:53:36 +0300 Subject: [PATCH 08/15] chore: make Transport property init-only and required --- src/Index.cs | 2 +- src/PineconeClient.cs | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Index.cs b/src/Index.cs index be1c70e..87541db 100644 --- a/src/Index.cs +++ b/src/Index.cs @@ -54,7 +54,7 @@ public sealed partial record Index : IDisposable /// The transport layer. /// [JsonIgnore] - internal TTransport Transport { get; set; } = default!; + public required TTransport Transport { private get; init; } /// /// Returns statistics describing the contents of an index, including the vector count per namespace and the number of dimensions, and the index fullness. diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index 5c6044b..017271a 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -149,13 +149,12 @@ public async Task> GetIndex< #endif where TTransport : ITransport { - var response = (IndexDetails)(await Http + var response = await Http .GetFromJsonAsync( $"/indexes/{UrlEncoder.Default.Encode(name)}", - typeof(IndexDetails), - SerializerContext.Default, + SerializerContext.Default.IndexDetails, cancellationToken) - .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed.")); + .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed."); // TODO: Host is optional according to the API spec: https://docs.pinecone.io/reference/api/control-plane/describe_index // but Transport requires it @@ -170,13 +169,12 @@ public async Task> GetIndex< Host = response.Host, Spec = response.Spec, Status = response.Status, - }; - #if NET7_0_OR_GREATER - index.Transport = TTransport.Create(host, apiKey); + Transport = TTransport.Create(host, apiKey) #else - index.Transport = ITransport.Create(host, apiKey); + Transport = ITransport.Create(host, apiKey) #endif + }; return index; } From f36a68b6b55df4ba0a64c19f5feaaf7b21ea2459 Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 17:44:30 +0300 Subject: [PATCH 09/15] ci: add pinecone test env api key test: parallelize index deletion on cleanup --- .github/workflows/dotnet-releaser.yaml | 6 ++++-- test/DataTestFixtureBase.cs | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dotnet-releaser.yaml b/.github/workflows/dotnet-releaser.yaml index 7a6b24c..cb39c55 100644 --- a/.github/workflows/dotnet-releaser.yaml +++ b/.github/workflows/dotnet-releaser.yaml @@ -22,8 +22,10 @@ jobs: 6.0.x 7.0.x 8.0.x - - name: CI/CD + - name: Install dotnet-releaser + run: dotnet tool install -g dotnet-releaser + - name: Run dotnet-releaser shell: bash run: | - dotnet tool install -g dotnet-releaser + dotnet user-secrets set "PineconeApiKey" "${{secrets.PINECONE_API_KEY}}" dotnet-releaser run --nuget-token "${{secrets.NUGET_TOKEN}}" --github-token "${{secrets.GITHUB_TOKEN}}" dotnet-releaser.toml \ No newline at end of file diff --git a/test/DataTestFixtureBase.cs b/test/DataTestFixtureBase.cs index 4a59346..0ff3b8a 100644 --- a/test/DataTestFixtureBase.cs +++ b/test/DataTestFixtureBase.cs @@ -143,10 +143,10 @@ public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace private async Task ClearIndexesAsync() { - foreach (var existingIndex in await Pinecone.ListIndexes()) - { - await DeleteExistingIndexAndWaitAsync(existingIndex.Name); - } + var indexes = await Pinecone.ListIndexes(); + var deletions = indexes.Select(x => DeleteExistingIndexAndWaitAsync(x.Name)); + + await Task.WhenAll(deletions); } private async Task DeleteExistingIndexAndWaitAsync(string indexName) From bf25c63cc3ca3ca3d4c5b6b8d6bca60b412ca220 Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 17:48:47 +0300 Subject: [PATCH 10/15] ci: specify project path for user secrets manager --- .github/workflows/dotnet-releaser.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-releaser.yaml b/.github/workflows/dotnet-releaser.yaml index cb39c55..3a36bcc 100644 --- a/.github/workflows/dotnet-releaser.yaml +++ b/.github/workflows/dotnet-releaser.yaml @@ -27,5 +27,5 @@ jobs: - name: Run dotnet-releaser shell: bash run: | - dotnet user-secrets set "PineconeApiKey" "${{secrets.PINECONE_API_KEY}}" + dotnet user-secrets set "PineconeApiKey" "${{secrets.PINECONE_API_KEY}}" --project test dotnet-releaser run --nuget-token "${{secrets.NUGET_TOKEN}}" --github-token "${{secrets.GITHUB_TOKEN}}" dotnet-releaser.toml \ No newline at end of file From 2fd26b24f97614af1e0f80128983b850b3ca7caa Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 18:06:39 +0300 Subject: [PATCH 11/15] test: un-parallelize index deletion, whoops (doesn't seem to help though) --- test/DataTestFixtureBase.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/DataTestFixtureBase.cs b/test/DataTestFixtureBase.cs index 0ff3b8a..4a59346 100644 --- a/test/DataTestFixtureBase.cs +++ b/test/DataTestFixtureBase.cs @@ -143,10 +143,10 @@ public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace private async Task ClearIndexesAsync() { - var indexes = await Pinecone.ListIndexes(); - var deletions = indexes.Select(x => DeleteExistingIndexAndWaitAsync(x.Name)); - - await Task.WhenAll(deletions); + foreach (var existingIndex in await Pinecone.ListIndexes()) + { + await DeleteExistingIndexAndWaitAsync(existingIndex.Name); + } } private async Task DeleteExistingIndexAndWaitAsync(string indexName) From 63d5df818b3854880f46a05fc8bed0c82ba5fb9c Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Fri, 17 May 2024 18:20:39 +0300 Subject: [PATCH 12/15] test: add a workaround for racing(?) DeleteIndex requests --- test/IndexTests.cs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/test/IndexTests.cs b/test/IndexTests.cs index a7f81ae..05544b0 100644 --- a/test/IndexTests.cs +++ b/test/IndexTests.cs @@ -77,18 +77,26 @@ public async Task Create_and_delete_index(Metric metric, bool serverless) private async Task DeleteIndexAndWait(PineconeClient pinecone, string indexName) { - await pinecone.DeleteIndex(indexName); + try + { + await pinecone.DeleteIndex(indexName); - List existingIndexes; - var attemptCount = 0; - // wait until old index has been deleted - do + List existingIndexes; + var attemptCount = 0; + // wait until old index has been deleted + do + { + await Task.Delay(DelayInterval); + attemptCount++; + existingIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); + } + while (existingIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); + } + // TODO: This is a questionable workaround but does the job for now + catch (HttpRequestException ex) when (ex.Message.Contains("NOT_FOUND")) { - await Task.Delay(DelayInterval); - attemptCount++; - existingIndexes = (await pinecone.ListIndexes()).Select(x => x.Name).ToList(); + // index was already deleted } - while (existingIndexes.Contains(indexName) && attemptCount < MaxAttemptCount); } [PineconeFact] From 5d78b149e148f835bcb99e54b04226f3b126c0e1 Mon Sep 17 00:00:00 2001 From: maumar Date: Fri, 17 May 2024 17:43:20 -0700 Subject: [PATCH 13/15] switching type of pods/shards/replicas from long to uint, adding missing test coverage for query with complex metadata --- src/PineconeClient.cs | 6 +++--- src/Types/IndexTypes.cs | 6 +++--- test/DataTestBase.cs | 30 ++++++++++++++++++++++++++++++ test/IndexTests.cs | 1 - 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index 017271a..c796237 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -84,9 +84,9 @@ public Task CreatePodBasedIndex( Metric metric, string environment, string podType = "p1.x1", - long? pods = 1, - long? shards = 1, - long? replicas = 1, + uint? pods = 1, + uint? shards = 1, + uint? replicas = 1, CancellationToken cancellationToken = default) => CreateIndexAsync(new CreateIndexRequest { diff --git a/src/Types/IndexTypes.cs b/src/Types/IndexTypes.cs index 376e136..ffd6018 100644 --- a/src/Types/IndexTypes.cs +++ b/src/Types/IndexTypes.cs @@ -149,19 +149,19 @@ public record PodSpec /// The number of pods used. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public long? Pods { get; init; } + public uint? Pods { get; init; } /// /// The number od replicas. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public long? Replicas { get; init; } + public uint? Replicas { get; init; } /// /// The number of shards. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public long? Shards { get; init; } + public uint? Shards { get; init; } /// /// Configuration for the behavior of internal metadata index. By default, all metadata is indexed. diff --git a/test/DataTestBase.cs b/test/DataTestBase.cs index 05b36e0..9de95d9 100644 --- a/test/DataTestBase.cs +++ b/test/DataTestBase.cs @@ -123,6 +123,36 @@ public async Task Query_with_metadata_list_contains() Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[1].Values); } + [PineconeFact] + public async Task Query_with_metadata_complex() + { + var filter = new MetadataMap + { + ["$or"] = new List + { + new MetadataMap() { ["rank"] = new MetadataMap() { ["$gt"] = 10 } }, + new MetadataMap() + { + ["$and"] = new List + { + new MetadataMap() { ["subtype"] = "primes" }, + new MetadataMap() { ["overhyped"] = false } + } + } + } + }; + + var result = await Fixture.Index.Query([3, 4, 5, 6, 7, 8, 9, 10], topK: 10, filter, includeMetadata: true); + + Assert.Equal(2, result.Length); + var ordered = result.OrderBy(x => x.Id).ToList(); + + Assert.Equal("metadata-vector-1", ordered[0].Id); + Assert.Equal([2, 3, 5, 7, 11, 13, 17, 19], ordered[0].Values); + Assert.Equal("metadata-vector-3", ordered[1].Id); + Assert.Equal([2, 1, 3, 4, 7, 11, 18, 29], ordered[1].Values); + } + [PineconeFact] public async Task Basic_fetch() { diff --git a/test/IndexTests.cs b/test/IndexTests.cs index 05544b0..46ad0f7 100644 --- a/test/IndexTests.cs +++ b/test/IndexTests.cs @@ -31,7 +31,6 @@ public async Task Create_and_delete_index(Metric metric, bool serverless) { // delete the previous index await DeleteIndexAndWait(pinecone, indexName); - //await pinecone.DeleteIndex(indexName); } // if we create pod-based index, we need to delete any previous gcp-starter indexes From 8e3acf3a500a657374dad5977c714cf5de59de53 Mon Sep 17 00:00:00 2001 From: maumar Date: Fri, 17 May 2024 17:56:05 -0700 Subject: [PATCH 14/15] adding maumar to the list of contributors --- src/Pinecone.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pinecone.csproj b/src/Pinecone.csproj index 2565732..681e761 100644 --- a/src/Pinecone.csproj +++ b/src/Pinecone.csproj @@ -2,7 +2,7 @@ Pinecone.NET - neon-sunset + neon-sunset, maumar MIT https://github.com/neon-sunset/Pinecone.NET https://github.com/neon-sunset/Pinecone.NET From 9ee3efd7abd152552b9eb485f6555f7ec61d4cf8 Mon Sep 17 00:00:00 2001 From: neon-sunset Date: Sat, 18 May 2024 17:02:48 +0300 Subject: [PATCH 15/15] refactor: make all async calls use CancellationToken consistently test: clear indexes in parallel test: guard against delete called on an already deleted index in one more place docs: generate XML documentation docs: fix VS Code config for C# example docs: make C# example publish AOT --- .vscode/launch.json | 12 ++- .vscode/tasks.json | 12 +-- example/Example.CSharp/Example.CSharp.csproj | 5 ++ src/Extensions.cs | 10 ++- src/Grpc/GrpcTransport.cs | 43 +++++----- src/ITransport.cs | 20 +++-- src/Index.cs | 47 +++++----- src/Pinecone.csproj | 1 + src/PineconeClient.cs | 90 +++++++++----------- src/Rest/RestTransport.cs | 60 ++++++------- test/DataTestFixtureBase.cs | 37 +++++--- test/Xunit/PineconeFactTestCase.cs | 4 +- test/Xunit/PineconeTheoryTestCase.cs | 4 +- 13 files changed, 182 insertions(+), 163 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 2ab4c65..2ca9d65 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,19 +1,17 @@ { + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md "name": ".NET Core Launch (console)", "type": "coreclr", "request": "launch", "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/example/bin/Debug/net8.0/Example.dll", + "program": "${workspaceFolder}/example/Example.CSharp/bin/Debug/net8.0/Example.CSharp.dll", "args": [], - "cwd": "${workspaceFolder}/example", - // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "cwd": "${workspaceFolder}/example/Example.CSharp", "console": "internalConsole", "stopAtEntry": false }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d600d15..51d153f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,9 +7,9 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/example/Example.csproj", + "${workspaceFolder}/example/Example.CSharp/Example.CSharp.csproj", "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" + "/consoleloggerparameters:NoSummary;ForceNoAlign" ], "problemMatcher": "$msCompile" }, @@ -19,9 +19,9 @@ "type": "process", "args": [ "publish", - "${workspaceFolder}/example/Example.csproj", + "${workspaceFolder}/example/Example.CSharp/Example.CSharp.csproj", "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" + "/consoleloggerparameters:NoSummary;ForceNoAlign" ], "problemMatcher": "$msCompile" }, @@ -33,9 +33,9 @@ "watch", "run", "--project", - "${workspaceFolder}/example/Example.csproj" + "${workspaceFolder}/example/Example.CSharp/Example.CSharp.csproj" ], "problemMatcher": "$msCompile" } ] -} +} \ No newline at end of file diff --git a/example/Example.CSharp/Example.CSharp.csproj b/example/Example.CSharp/Example.CSharp.csproj index 5ef186c..dff880b 100644 --- a/example/Example.CSharp/Example.CSharp.csproj +++ b/example/Example.CSharp/Example.CSharp.csproj @@ -9,6 +9,11 @@ false + + true + true + + diff --git a/src/Extensions.cs b/src/Extensions.cs index 0c389f6..4bdb0c3 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -7,15 +7,17 @@ namespace Pinecone; internal static class Extensions { [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static ValueTask CheckStatusCode(this HttpResponseMessage response, [CallerMemberName] string requestName = "") + internal static ValueTask CheckStatusCode( + this HttpResponseMessage response, CancellationToken ct, [CallerMemberName] string requestName = "") { - return response.IsSuccessStatusCode ? ValueTask.CompletedTask : ThrowOnFailedResponse(response, requestName); + return response.IsSuccessStatusCode ? ValueTask.CompletedTask : ThrowOnFailedResponse(response, requestName, ct); [DoesNotReturn, StackTraceHidden] - static async ValueTask ThrowOnFailedResponse(HttpResponseMessage response, string requestName) + static async ValueTask ThrowOnFailedResponse( + HttpResponseMessage response, string requestName, CancellationToken ct) { throw new HttpRequestException($"{requestName} request has failed. " + - $"Code: {response.StatusCode}. Message: {await response.Content.ReadAsStringAsync().ConfigureAwait(false)}"); + $"Code: {response.StatusCode}. Message: {await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)}"); } } } diff --git a/src/Grpc/GrpcTransport.cs b/src/Grpc/GrpcTransport.cs index 5544fe0..5512309 100644 --- a/src/Grpc/GrpcTransport.cs +++ b/src/Grpc/GrpcTransport.cs @@ -24,7 +24,7 @@ public GrpcTransport(string host, string apiKey) public static GrpcTransport Create(string host, string apiKey) => new(host, apiKey); - public async Task DescribeStats(MetadataMap? filter = null) + public async Task DescribeStats(MetadataMap? filter = null, CancellationToken ct = default) { var request = new DescribeIndexStatsRequest(); if (filter != null) @@ -32,7 +32,7 @@ public async Task DescribeStats(MetadataMap? filter = null) request.Filter = filter.ToProtoStruct(); } - using var call = Grpc.DescribeIndexStatsAsync(request, Auth); + using var call = Grpc.DescribeIndexStatsAsync(request, Auth, cancellationToken: ct); return (await call.ConfigureAwait(false)).ToPublicType(); } @@ -45,7 +45,8 @@ public async Task Query( MetadataMap? filter, string? indexNamespace, bool includeValues, - bool includeMetadata) + bool includeMetadata, + CancellationToken ct = default) { var request = new QueryRequest() { @@ -71,7 +72,7 @@ public async Task Query( "At least one of the following parameters must be non-null: id, values, sparseValues"); } - using var call = Grpc.QueryAsync(request, Auth); + using var call = Grpc.QueryAsync(request, Auth, cancellationToken: ct); var response = await call.ConfigureAwait(false); var matches = response.Matches; @@ -84,29 +85,31 @@ public async Task Query( return vectors; } - public async Task Upsert(IEnumerable vectors, string? indexNamespace = null) + public async Task Upsert(IEnumerable vectors, string? indexNamespace = null, CancellationToken ct = default) { var request = new UpsertRequest { Namespace = indexNamespace ?? "" }; request.Vectors.AddRange(vectors.Select(v => v.ToProtoVector())); - using var call = Grpc.UpsertAsync(request, Auth); + using var call = Grpc.UpsertAsync(request, Auth, cancellationToken: ct); return (await call.ConfigureAwait(false)).UpsertedCount; } - public Task Update(Vector vector, string? indexNamespace = null) => Update( + public Task Update(Vector vector, string? indexNamespace = null, CancellationToken ct = default) => Update( vector.Id, vector.Values, vector.SparseValues, vector.Metadata, - indexNamespace); + indexNamespace, + ct); public async Task Update( string id, float[]? values = null, SparseVector? sparseValues = null, MetadataMap? metadata = null, - string? indexNamespace = null) + string? indexNamespace = null, + CancellationToken ct = default) { if (values is null && sparseValues is null && metadata is null) { @@ -123,12 +126,12 @@ public async Task Update( }; request.Values.OverwriteWith(values); - using var call = Grpc.UpdateAsync(request, Auth); + using var call = Grpc.UpdateAsync(request, Auth, cancellationToken: ct); _ = await call.ConfigureAwait(false); } public async Task> Fetch( - IEnumerable ids, string? indexNamespace = null) + IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default) { var request = new FetchRequest { @@ -136,7 +139,7 @@ public async Task> Fetch( Namespace = indexNamespace ?? "" }; - using var call = Grpc.FetchAsync(request, Auth); + using var call = Grpc.FetchAsync(request, Auth, cancellationToken: ct); var response = await call.ConfigureAwait(false); return response.Vectors.ToDictionary( @@ -144,28 +147,28 @@ public async Task> Fetch( kvp => kvp.Value.ToPublicType()); } - public Task Delete(IEnumerable ids, string? indexNamespace = null) => + public Task Delete(IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default) => Delete(new() { Ids = { ids }, DeleteAll = false, Namespace = indexNamespace ?? "" - }); + }, ct); - public Task Delete(MetadataMap filter, string? indexNamespace = null) => + public Task Delete(MetadataMap filter, string? indexNamespace = null, CancellationToken ct = default) => Delete(new() { Filter = filter.ToProtoStruct(), DeleteAll = false, Namespace = indexNamespace ?? "" - }); + }, ct); - public Task DeleteAll(string? indexNamespace = null) => - Delete(new() { DeleteAll = true, Namespace = indexNamespace ?? "" }); + public Task DeleteAll(string? indexNamespace = null, CancellationToken ct = default) => + Delete(new() { DeleteAll = true, Namespace = indexNamespace ?? "" }, ct); - private async Task Delete(DeleteRequest request) + private async Task Delete(DeleteRequest request, CancellationToken ct) { - using var call = Grpc.DeleteAsync(request, Auth); + using var call = Grpc.DeleteAsync(request, Auth, cancellationToken: ct); _ = await call.ConfigureAwait(false); } diff --git a/src/ITransport.cs b/src/ITransport.cs index d47fb76..b1ce183 100644 --- a/src/ITransport.cs +++ b/src/ITransport.cs @@ -33,7 +33,7 @@ static T Create(string host, string apiKey) } #endif - Task DescribeStats(MetadataMap? filter = null); + Task DescribeStats(MetadataMap? filter = null, CancellationToken ct = default); Task Query( string? id, float[]? values, @@ -42,17 +42,19 @@ Task Query( MetadataMap? filter, string? indexNamespace, bool includeValues, - bool includeMetadata); - Task Upsert(IEnumerable vectors, string? indexNamespace = null); - Task Update(Vector vector, string? indexNamespace = null); + bool includeMetadata, + CancellationToken ct = default); + Task Upsert(IEnumerable vectors, string? indexNamespace = null, CancellationToken ct = default); + Task Update(Vector vector, string? indexNamespace = null, CancellationToken ct = default); Task Update( string id, float[]? values = null, SparseVector? sparseValues = null, MetadataMap? metadata = null, - string? indexNamespace = null); - Task> Fetch(IEnumerable ids, string? indexNamespace = null); - Task Delete(IEnumerable ids, string? indexNamespace = null); - Task Delete(MetadataMap filter, string? indexNamespace = null); - Task DeleteAll(string? indexNamespace = null); + string? indexNamespace = null, + CancellationToken ct = default); + Task> Fetch(IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default); + Task Delete(IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default); + Task Delete(MetadataMap filter, string? indexNamespace = null, CancellationToken ct = default); + Task DeleteAll(string? indexNamespace = null, CancellationToken ct = default); } diff --git a/src/Index.cs b/src/Index.cs index 87541db..0d4258a 100644 --- a/src/Index.cs +++ b/src/Index.cs @@ -61,9 +61,9 @@ public sealed partial record Index : IDisposable /// /// The operation only returns statistics for vectors that satisfy the filter. /// An object containing index statistics. - public Task DescribeStats(MetadataMap? filter = null) + public Task DescribeStats(MetadataMap? filter = null, CancellationToken ct = default) { - return Transport.DescribeStats(filter); + return Transport.DescribeStats(filter, ct); } /// @@ -71,8 +71,6 @@ public Task DescribeStats(MetadataMap? filter = null) /// /// Query by ID uses Approximate Nearest Neighbor, which doesn't guarantee the input vector to appear in the results. To ensure that, use the Fetch operation instead. /// The unique ID of the vector to be used as a query vector. - /// The query vector. This should be the same length as the dimension of the index being queried. - /// Vector sparse data. Represented as a list of indices and a list of corresponded values, which must be with the same length. /// The number of results to return for each query. /// The filter to apply. /// Namespace to query from. If no namespace is provided, the operation applies to all namespaces. @@ -85,7 +83,8 @@ public Task Query( MetadataMap? filter = null, string? indexNamespace = null, bool includeValues = true, - bool includeMetadata = false) + bool includeMetadata = false, + CancellationToken ct = default) { return Transport.Query( id: id, @@ -95,7 +94,8 @@ public Task Query( filter: filter, indexNamespace: indexNamespace, includeValues: includeValues, - includeMetadata: includeMetadata); + includeMetadata: includeMetadata, + ct: ct); } /// @@ -116,7 +116,8 @@ public Task Query( SparseVector? sparseValues = null, string? indexNamespace = null, bool includeValues = true, - bool includeMetadata = false) + bool includeMetadata = false, + CancellationToken ct = default) { return Transport.Query( id: null, @@ -126,7 +127,8 @@ public Task Query( filter: filter, indexNamespace: indexNamespace, includeValues: includeValues, - includeMetadata: includeMetadata); + includeMetadata: includeMetadata, + ct: ct); } /// @@ -135,9 +137,9 @@ public Task Query( /// A collection of objects to upsert. /// Namespace to write the vector to. If no namespace is provided, the operation applies to all namespaces. /// The number of vectors upserted. - public Task Upsert(IEnumerable vectors, string? indexNamespace = null) + public Task Upsert(IEnumerable vectors, string? indexNamespace = null, CancellationToken ct = default) { - return Transport.Upsert(vectors, indexNamespace); + return Transport.Upsert(vectors, indexNamespace, ct); } /// @@ -145,9 +147,9 @@ public Task Upsert(IEnumerable vectors, string? indexNamespace = n /// /// object containing updated information. /// Namespace to update the vector from. If no namespace is provided, the operation applies to all namespaces. - public Task Update(Vector vector, string? indexNamespace = null) + public Task Update(Vector vector, string? indexNamespace = null, CancellationToken ct = default) { - return Transport.Update(vector, indexNamespace); + return Transport.Update(vector, indexNamespace, ct); } /// @@ -163,9 +165,10 @@ public Task Update( float[]? values = null, SparseVector? sparseValues = null, MetadataMap? metadata = null, - string? indexNamespace = null) + string? indexNamespace = null, + CancellationToken ct = default) { - return Transport.Update(id, values, sparseValues, metadata, indexNamespace); + return Transport.Update(id, values, sparseValues, metadata, indexNamespace, ct); } /// @@ -174,9 +177,9 @@ public Task Update( /// IDs of vectors to fetch. /// Namespace to fetch vectors from. If no namespace is provided, the operation applies to all namespaces. /// A dictionary containing vector IDs and the corresponding objects containing the vector information. - public Task> Fetch(IEnumerable ids, string? indexNamespace = null) + public Task> Fetch(IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default) { - return Transport.Fetch(ids, indexNamespace); + return Transport.Fetch(ids, indexNamespace, ct); } /// @@ -184,9 +187,9 @@ public Task> Fetch(IEnumerable ids, string? i /// /// /// Namespace to delete vectors from. If no namespace is provided, the operation applies to all namespaces. - public Task Delete(IEnumerable ids, string? indexNamespace = null) + public Task Delete(IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default) { - return Transport.Delete(ids, indexNamespace); + return Transport.Delete(ids, indexNamespace, ct); } /// @@ -194,18 +197,18 @@ public Task Delete(IEnumerable ids, string? indexNamespace = null) /// /// Filter used to select vectors to delete. /// Namespace to delete vectors from. If no namespace is provided, the operation applies to all namespaces. - public Task Delete(MetadataMap filter, string? indexNamespace = null) + public Task Delete(MetadataMap filter, string? indexNamespace = null, CancellationToken ct = default) { - return Transport.Delete(filter, indexNamespace); + return Transport.Delete(filter, indexNamespace, ct); } /// /// Deletes all vectors. /// /// Namespace to delete vectors from. If no namespace is provided, the operation applies to all namespaces. - public Task DeleteAll(string? indexNamespace = null) + public Task DeleteAll(string? indexNamespace = null, CancellationToken ct = default) { - return Transport.DeleteAll(indexNamespace); + return Transport.DeleteAll(indexNamespace, ct); } /// diff --git a/src/Pinecone.csproj b/src/Pinecone.csproj index 681e761..c32c1ff 100644 --- a/src/Pinecone.csproj +++ b/src/Pinecone.csproj @@ -19,6 +19,7 @@ In the absence of an official SDK, it provides first-class support for Pinecone enable 12 enable + true diff --git a/src/PineconeClient.cs b/src/PineconeClient.cs index c796237..3848ba9 100644 --- a/src/PineconeClient.cs +++ b/src/PineconeClient.cs @@ -54,12 +54,12 @@ public PineconeClient(string apiKey, HttpClient client) /// /// Returns a list of indexes in the project. /// - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// List of index descriptions for all indexes in the project. - public async Task ListIndexes(CancellationToken cancellationToken = default) + public async Task ListIndexes(CancellationToken ct = default) { - var listIndexesResult = (ListIndexesResult?)await Http - .GetFromJsonAsync("/indexes", typeof(ListIndexesResult), SerializerContext.Default, cancellationToken) + var listIndexesResult = await Http + .GetFromJsonAsync("/indexes", SerializerContext.Default.ListIndexesResult, ct) .ConfigureAwait(false); return listIndexesResult?.Indexes ?? []; @@ -76,7 +76,7 @@ public async Task ListIndexes(CancellationToken cancellationToke /// Number of pods to use. This should be equal to number of shards multiplied by the number of replicas. /// Number of shards to split the data across multiple pods. /// Number of replicas. Replicas duplicate the index for greater availability and throughput. - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// public Task CreatePodBasedIndex( string name, @@ -87,14 +87,14 @@ public Task CreatePodBasedIndex( uint? pods = 1, uint? shards = 1, uint? replicas = 1, - CancellationToken cancellationToken = default) + CancellationToken ct = default) => CreateIndexAsync(new CreateIndexRequest { Name = name, Dimension = dimension, Metric = metric, Spec = new IndexSpec { Pod = new PodSpec { Environment = environment, PodType = podType, Pods = pods, Replicas = replicas, Shards = shards } } - }, cancellationToken); + }, ct); /// /// Creates a serverless index. Serverless indexes scale dynamically based on usage. @@ -104,24 +104,24 @@ public Task CreatePodBasedIndex( /// The distance metric used for similarity search. /// The public cloud where the index will be hosted. /// The region where the index will be created. - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// - public Task CreateServerlessIndex(string name, uint dimension, Metric metric, string cloud, string region, CancellationToken cancellationToken = default) + public Task CreateServerlessIndex(string name, uint dimension, Metric metric, string cloud, string region, CancellationToken ct = default) => CreateIndexAsync(new CreateIndexRequest { Name = name, Dimension = dimension, Metric = metric, Spec = new IndexSpec { Serverless = new ServerlessSpec { Cloud = cloud, Region = region } } - }, cancellationToken); + }, ct); - private async Task CreateIndexAsync(CreateIndexRequest request, CancellationToken cancellationToken = default) + private async Task CreateIndexAsync(CreateIndexRequest request, CancellationToken ct = default) { var response = await Http - .PostAsJsonAsync("/indexes", request, SerializerContext.Default.CreateIndexRequest, cancellationToken) + .PostAsJsonAsync("/indexes", request, SerializerContext.Default.CreateIndexRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); } /// @@ -129,9 +129,9 @@ private async Task CreateIndexAsync(CreateIndexRequest request, CancellationToke /// It is used to upsert, query, fetch, update, delete and list vectors, as well as retrieving index statistics. /// /// Name of the index to describe. - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// describing the index. - public Task> GetIndex(string name, CancellationToken cancellationToken = default) => GetIndex(name, cancellationToken); + public Task> GetIndex(string name, CancellationToken ct = default) => GetIndex(name, ct); /// /// Creates an object describing the index. It is a main entry point for interacting with vectors. @@ -139,21 +139,18 @@ private async Task CreateIndexAsync(CreateIndexRequest request, CancellationToke /// /// The type of transport layer used, either or . /// Name of the index to describe. - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// describing the index. #if NET7_0_OR_GREATER - public async Task> GetIndex(string name, CancellationToken cancellationToken = default) + public async Task> GetIndex(string name, CancellationToken ct = default) #else public async Task> GetIndex< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransport>(string name, CancellationToken cancellationToken = default) + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransport>(string name, CancellationToken ct = default) #endif where TTransport : ITransport { var response = await Http - .GetFromJsonAsync( - $"/indexes/{UrlEncoder.Default.Encode(name)}", - SerializerContext.Default.IndexDetails, - cancellationToken) + .GetFromJsonAsync($"/indexes/{UrlEncoder.Default.Encode(name)}", SerializerContext.Default.IndexDetails, ct) .ConfigureAwait(false) ?? throw new HttpRequestException("GetIndex request has failed."); // TODO: Host is optional according to the API spec: https://docs.pinecone.io/reference/api/control-plane/describe_index @@ -185,8 +182,8 @@ public async Task> GetIndex< /// Name of the pod-based index to configure. /// The new number or replicas. /// The new pod type. - /// A to observe while waiting for the task to complete. - public async Task ConfigureIndex(string name, int? replicas = null, string? podType = null, CancellationToken cancellationToken = default) + /// A to observe while waiting for the task to complete. + public async Task ConfigureIndex(string name, int? replicas = null, string? podType = null, CancellationToken ct = default) { if (replicas is null && podType is null or []) { @@ -200,33 +197,31 @@ public async Task ConfigureIndex(string name, int? replicas = null, string? podT $"/indexes/{UrlEncoder.Default.Encode(name)}", request, SerializerContext.Default.ConfigureIndexRequest, - cancellationToken) + ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); } /// /// Deletes an existing index. /// /// Name of index to delete. - /// A to observe while waiting for the task to complete. - public async Task DeleteIndex(string name, CancellationToken cancellationToken = default) => - await (await Http.DeleteAsync($"/indexes/{UrlEncoder.Default.Encode(name)}", cancellationToken).ConfigureAwait(false)) - .CheckStatusCode() + /// A to observe while waiting for the task to complete. + public async Task DeleteIndex(string name, CancellationToken ct = default) => + await (await Http.DeleteAsync($"/indexes/{UrlEncoder.Default.Encode(name)}", ct).ConfigureAwait(false)) + .CheckStatusCode(ct) .ConfigureAwait(false); /// /// Returns a list of collections in the project. /// - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// List of collection descriptions for all collections in the project. - public async Task ListCollections(CancellationToken cancellationToken = default) + public async Task ListCollections(CancellationToken ct = default) { - var listCollectionsResult = (ListCollectionsResult?)await Http - .GetFromJsonAsync("/collections", typeof(ListCollectionsResult), - SerializerContext.Default, - cancellationToken) + var listCollectionsResult = await Http + .GetFromJsonAsync("/collections", SerializerContext.Default.ListCollectionsResult, ct) .ConfigureAwait(false); return listCollectionsResult?.Collections ?? []; @@ -237,31 +232,30 @@ public async Task ListCollections(CancellationToken cancell /// /// Name of the collection to create. /// The name of the index to be used as the source for the collection. - /// A to observe while waiting for the task to complete. - public async Task CreateCollection(string name, string source, CancellationToken cancellationToken = default) + /// A to observe while waiting for the task to complete. + public async Task CreateCollection(string name, string source, CancellationToken ct = default) { var request = new CreateCollectionRequest { Name = name, Source = source }; var response = await Http - .PostAsJsonAsync("/collections", request, SerializerContext.Default.CreateCollectionRequest, - cancellationToken) + .PostAsJsonAsync("/collections", request, SerializerContext.Default.CreateCollectionRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); } /// /// Gets a description of a collection. /// /// Name of the collection to describe. - /// A to observe while waiting for the task to complete. + /// A to observe while waiting for the task to complete. /// A describing the collection. - public async Task DescribeCollection(string name, CancellationToken cancellationToken = default) + public async Task DescribeCollection(string name, CancellationToken ct = default) { return await Http .GetFromJsonAsync( $"/collections/{UrlEncoder.Default.Encode(name)}", SerializerContext.Default.CollectionDetails, - cancellationToken) + ct) .ConfigureAwait(false) ?? ThrowHelpers.JsonException(); } @@ -269,10 +263,10 @@ public async Task DescribeCollection(string name, Cancellatio /// Deletes an existing collection. /// /// Name of the collection to delete. - /// A to observe while waiting for the task to complete. - public async Task DeleteCollection(string name, CancellationToken cancellationToken = default) => - await (await Http.DeleteAsync($"/collections/{UrlEncoder.Default.Encode(name)}", cancellationToken)) - .CheckStatusCode() + /// A to observe while waiting for the task to complete. + public async Task DeleteCollection(string name, CancellationToken ct = default) => + await (await Http.DeleteAsync($"/collections/{UrlEncoder.Default.Encode(name)}", ct)) + .CheckStatusCode(ct) .ConfigureAwait(false); /// diff --git a/src/Rest/RestTransport.cs b/src/Rest/RestTransport.cs index 4a449d9..ca65d79 100644 --- a/src/Rest/RestTransport.cs +++ b/src/Rest/RestTransport.cs @@ -21,16 +21,16 @@ public RestTransport(string host, string apiKey) public static RestTransport Create(string host, string apiKey) => new(host, apiKey); - public async Task DescribeStats(MetadataMap? filter = null) + public async Task DescribeStats(MetadataMap? filter = null, CancellationToken ct = default) { var request = new DescribeStatsRequest { Filter = filter }; var response = await Http - .PostAsJsonAsync("/describe_index_stats", request, SerializerContext.Default.DescribeStatsRequest) + .PostAsJsonAsync("/describe_index_stats", request, SerializerContext.Default.DescribeStatsRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); return await response.Content - .ReadFromJsonAsync(SerializerContext.Default.IndexStats) + .ReadFromJsonAsync(SerializerContext.Default.IndexStats, ct) .ConfigureAwait(false) ?? ThrowHelpers.JsonException(); } @@ -42,7 +42,8 @@ public async Task Query( MetadataMap? filter, string? indexNamespace, bool includeValues, - bool includeMetadata) + bool includeMetadata, + CancellationToken ct = default) { if (id is null && values is null && sparseValues is null) { @@ -63,17 +64,17 @@ public async Task Query( }; var response = await Http - .PostAsJsonAsync("/query", request, SerializerContext.Default.QueryRequest) + .PostAsJsonAsync("/query", request, SerializerContext.Default.QueryRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); return (await response.Content - .ReadFromJsonAsync(SerializerContext.Default.QueryResponse) + .ReadFromJsonAsync(SerializerContext.Default.QueryResponse, ct) .ConfigureAwait(false)) .Matches ?? ThrowHelpers.JsonException(); } - public async Task Upsert(IEnumerable vectors, string? indexNamespace = null) + public async Task Upsert(IEnumerable vectors, string? indexNamespace = null, CancellationToken ct = default) { var request = new UpsertRequest { @@ -82,24 +83,24 @@ public async Task Upsert(IEnumerable vectors, string? indexNamespa }; var response = await Http - .PostAsJsonAsync("/vectors/upsert", request, SerializerContext.Default.UpsertRequest) + .PostAsJsonAsync("/vectors/upsert", request, SerializerContext.Default.UpsertRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); return (await response.Content - .ReadFromJsonAsync(SerializerContext.Default.UpsertResponse) + .ReadFromJsonAsync(SerializerContext.Default.UpsertResponse, ct) .ConfigureAwait(false)).UpsertedCount; } - public async Task Update(Vector vector, string? indexNamespace = null) + public async Task Update(Vector vector, string? indexNamespace = null, CancellationToken ct = default) { var request = UpdateRequest.From(vector, indexNamespace); Debug.Assert(request.Metadata is null); var response = await Http - .PostAsJsonAsync("/vectors/update", request, SerializerContext.Default.UpdateRequest) + .PostAsJsonAsync("/vectors/update", request, SerializerContext.Default.UpdateRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); } public async Task Update( @@ -107,7 +108,8 @@ public async Task Update( float[]? values = null, SparseVector? sparseValues = null, MetadataMap? metadata = null, - string? indexNamespace = null) + string? indexNamespace = null, + CancellationToken ct = default) { if (values is null && sparseValues is null && metadata is null) { @@ -125,13 +127,13 @@ public async Task Update( }; var response = await Http - .PostAsJsonAsync("/vectors/update", request, SerializerContext.Default.UpdateRequest) + .PostAsJsonAsync("/vectors/update", request, SerializerContext.Default.UpdateRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); } public async Task> Fetch( - IEnumerable ids, string? indexNamespace = null) + IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default) { using var enumerator = ids.GetEnumerator(); if (!enumerator.MoveNext()) @@ -149,35 +151,35 @@ public async Task> Fetch( } return (await Http - .GetFromJsonAsync(addressBuilder.ToString(), SerializerContext.Default.FetchResponse) + .GetFromJsonAsync(addressBuilder.ToString(), SerializerContext.Default.FetchResponse, ct) .ConfigureAwait(false)).Vectors; } - public Task Delete(IEnumerable ids, string? indexNamespace = null) => + public Task Delete(IEnumerable ids, string? indexNamespace = null, CancellationToken ct = default) => Delete(new() { Ids = ids as string[] ?? ids.ToArray(), DeleteAll = false, Namespace = indexNamespace ?? "" - }); + }, ct); - public Task Delete(MetadataMap filter, string? indexNamespace = null) => + public Task Delete(MetadataMap filter, string? indexNamespace = null, CancellationToken ct = default) => Delete(new() { Filter = filter, DeleteAll = false, Namespace = indexNamespace ?? "" - }); + }, ct); - public Task DeleteAll(string? indexNamespace = null) => - Delete(new() { DeleteAll = true, Namespace = indexNamespace ?? "" }); + public Task DeleteAll(string? indexNamespace = null, CancellationToken ct = default) => + Delete(new() { DeleteAll = true, Namespace = indexNamespace ?? "" }, ct); - private async Task Delete(DeleteRequest request) + private async Task Delete(DeleteRequest request, CancellationToken ct) { var response = await Http - .PostAsJsonAsync("/vectors/delete", request, SerializerContext.Default.DeleteRequest) + .PostAsJsonAsync("/vectors/delete", request, SerializerContext.Default.DeleteRequest, ct) .ConfigureAwait(false); - await response.CheckStatusCode().ConfigureAwait(false); + await response.CheckStatusCode(ct).ConfigureAwait(false); } public void Dispose() => Http.Dispose(); diff --git a/test/DataTestFixtureBase.cs b/test/DataTestFixtureBase.cs index 4a59346..d28075e 100644 --- a/test/DataTestFixtureBase.cs +++ b/test/DataTestFixtureBase.cs @@ -143,27 +143,36 @@ public async Task DeleteAndWait(IEnumerable ids, string? indexNamespace private async Task ClearIndexesAsync() { - foreach (var existingIndex in await Pinecone.ListIndexes()) - { - await DeleteExistingIndexAndWaitAsync(existingIndex.Name); - } + var indexes = await Pinecone.ListIndexes(); + var deletions = indexes.Select(x => DeleteExistingIndexAndWaitAsync(x.Name)); + + await Task.WhenAll(deletions); } private async Task DeleteExistingIndexAndWaitAsync(string indexName) { var exists = true; - var attemptCount = 0; - await Pinecone.DeleteIndex(indexName); - - do + try { - await Task.Delay(DelayInterval); - var indexes = (await Pinecone.ListIndexes()).Select(x => x.Name).ToArray(); - if (indexes.Length == 0 || !indexes.Contains(indexName)) + var attemptCount = 0; + await Pinecone.DeleteIndex(indexName); + + do { - exists = false; - } - } while (exists && attemptCount <= MaxAttemptCount); + await Task.Delay(DelayInterval); + var indexes = (await Pinecone.ListIndexes()).Select(x => x.Name).ToArray(); + if (indexes.Length == 0 || !indexes.Contains(indexName)) + { + exists = false; + } + } while (exists && attemptCount <= MaxAttemptCount); + } + // TODO: This is a questionable workaround but does the job for now + catch (HttpRequestException ex) when (ex.Message.Contains("NOT_FOUND")) + { + // index was already deleted + exists = false; + } if (exists) { diff --git a/test/Xunit/PineconeFactTestCase.cs b/test/Xunit/PineconeFactTestCase.cs index 905b81e..4c69a53 100644 --- a/test/Xunit/PineconeFactTestCase.cs +++ b/test/Xunit/PineconeFactTestCase.cs @@ -25,7 +25,7 @@ public override async Task RunAsync( IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, - CancellationTokenSource cancellationTokenSource) + CancellationTokenSource ctSource) => await XunitTestCaseExtensions.TrySkipAsync(this, messageBus) ? new RunSummary { Total = 1, Skipped = 1 } : await base.RunAsync( @@ -33,5 +33,5 @@ public override async Task RunAsync( messageBus, constructorArguments, aggregator, - cancellationTokenSource); + ctSource); } \ No newline at end of file diff --git a/test/Xunit/PineconeTheoryTestCase.cs b/test/Xunit/PineconeTheoryTestCase.cs index 9d0bfeb..8ff043d 100644 --- a/test/Xunit/PineconeTheoryTestCase.cs +++ b/test/Xunit/PineconeTheoryTestCase.cs @@ -23,7 +23,7 @@ public override async Task RunAsync( IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, - CancellationTokenSource cancellationTokenSource) + CancellationTokenSource ctSource) => await XunitTestCaseExtensions.TrySkipAsync(this, messageBus) ? new RunSummary { Total = 1, Skipped = 1 } : await base.RunAsync( @@ -31,5 +31,5 @@ public override async Task RunAsync( messageBus, constructorArguments, aggregator, - cancellationTokenSource); + ctSource); } \ No newline at end of file