vectra-client 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +77 -37
- data/CHANGELOG.md +85 -6
- data/README.md +56 -203
- data/docs/Gemfile +0 -1
- data/docs/_config.yml +19 -2
- data/docs/_layouts/default.html +6 -6
- data/docs/_layouts/home.html +183 -29
- data/docs/_layouts/page.html +81 -18
- data/docs/assets/style.css +806 -174
- data/docs/examples/index.md +46 -24
- data/docs/guides/monitoring.md +860 -0
- data/docs/guides/performance.md +200 -0
- data/docs/guides/runbooks/cache-issues.md +267 -0
- data/docs/guides/runbooks/high-error-rate.md +152 -0
- data/docs/guides/runbooks/high-latency.md +287 -0
- data/docs/guides/runbooks/pool-exhausted.md +216 -0
- data/docs/index.md +22 -38
- data/docs/providers/index.md +58 -39
- data/lib/vectra/batch.rb +148 -0
- data/lib/vectra/cache.rb +261 -0
- data/lib/vectra/circuit_breaker.rb +336 -0
- data/lib/vectra/client.rb +2 -0
- data/lib/vectra/configuration.rb +6 -1
- data/lib/vectra/health_check.rb +254 -0
- data/lib/vectra/instrumentation/honeybadger.rb +128 -0
- data/lib/vectra/instrumentation/sentry.rb +117 -0
- data/lib/vectra/logging.rb +242 -0
- data/lib/vectra/pool.rb +256 -0
- data/lib/vectra/rate_limiter.rb +304 -0
- data/lib/vectra/streaming.rb +153 -0
- data/lib/vectra/version.rb +1 -1
- data/lib/vectra.rb +8 -0
- metadata +31 -1
data/lib/vectra/batch.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Vectra
|
|
6
|
+
# Batch operations with concurrent processing
|
|
7
|
+
#
|
|
8
|
+
# Provides async batch upsert capabilities with configurable concurrency
|
|
9
|
+
# and automatic chunking of large vector sets.
|
|
10
|
+
#
|
|
11
|
+
# @example Async batch upsert
|
|
12
|
+
# batch = Vectra::Batch.new(client, concurrency: 4)
|
|
13
|
+
# result = batch.upsert_async(
|
|
14
|
+
# index: 'my-index',
|
|
15
|
+
# vectors: large_vector_array,
|
|
16
|
+
# chunk_size: 100
|
|
17
|
+
# )
|
|
18
|
+
# puts "Upserted: #{result[:upserted_count]}"
|
|
19
|
+
#
|
|
20
|
+
class Batch
|
|
21
|
+
DEFAULT_CONCURRENCY = 4
|
|
22
|
+
DEFAULT_CHUNK_SIZE = 100
|
|
23
|
+
|
|
24
|
+
attr_reader :client, :concurrency
|
|
25
|
+
|
|
26
|
+
# Initialize a new Batch processor
|
|
27
|
+
#
|
|
28
|
+
# @param client [Client] the Vectra client
|
|
29
|
+
# @param concurrency [Integer] max concurrent requests (default: 4)
|
|
30
|
+
def initialize(client, concurrency: DEFAULT_CONCURRENCY)
|
|
31
|
+
@client = client
|
|
32
|
+
@concurrency = [concurrency, 1].max
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Perform async batch upsert with concurrent requests
|
|
36
|
+
#
|
|
37
|
+
# @param index [String] the index name
|
|
38
|
+
# @param vectors [Array<Hash>] vectors to upsert
|
|
39
|
+
# @param namespace [String, nil] optional namespace
|
|
40
|
+
# @param chunk_size [Integer] vectors per chunk (default: 100)
|
|
41
|
+
# @return [Hash] aggregated result with :upserted_count, :chunks, :errors
|
|
42
|
+
def upsert_async(index:, vectors:, namespace: nil, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
43
|
+
chunks = vectors.each_slice(chunk_size).to_a
|
|
44
|
+
return { upserted_count: 0, chunks: 0, errors: [] } if chunks.empty?
|
|
45
|
+
|
|
46
|
+
results = process_chunks_concurrently(chunks) do |chunk|
|
|
47
|
+
client.upsert(index: index, vectors: chunk, namespace: namespace)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
aggregate_results(results, vectors.size)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Perform async batch delete with concurrent requests
|
|
54
|
+
#
|
|
55
|
+
# @param index [String] the index name
|
|
56
|
+
# @param ids [Array<String>] IDs to delete
|
|
57
|
+
# @param namespace [String, nil] optional namespace
|
|
58
|
+
# @param chunk_size [Integer] IDs per chunk (default: 100)
|
|
59
|
+
# @return [Hash] aggregated result
|
|
60
|
+
def delete_async(index:, ids:, namespace: nil, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
61
|
+
chunks = ids.each_slice(chunk_size).to_a
|
|
62
|
+
return { deleted_count: 0, chunks: 0, errors: [] } if chunks.empty?
|
|
63
|
+
|
|
64
|
+
results = process_chunks_concurrently(chunks) do |chunk|
|
|
65
|
+
client.delete(index: index, ids: chunk, namespace: namespace)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
aggregate_delete_results(results, ids.size)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Perform async batch fetch with concurrent requests
|
|
72
|
+
#
|
|
73
|
+
# @param index [String] the index name
|
|
74
|
+
# @param ids [Array<String>] IDs to fetch
|
|
75
|
+
# @param namespace [String, nil] optional namespace
|
|
76
|
+
# @param chunk_size [Integer] IDs per chunk (default: 100)
|
|
77
|
+
# @return [Hash<String, Vector>] merged results
|
|
78
|
+
def fetch_async(index:, ids:, namespace: nil, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
79
|
+
chunks = ids.each_slice(chunk_size).to_a
|
|
80
|
+
return {} if chunks.empty?
|
|
81
|
+
|
|
82
|
+
results = process_chunks_concurrently(chunks) do |chunk|
|
|
83
|
+
client.fetch(index: index, ids: chunk, namespace: namespace)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
merge_fetch_results(results)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def process_chunks_concurrently(chunks)
|
|
92
|
+
pool = Concurrent::FixedThreadPool.new(concurrency)
|
|
93
|
+
futures = []
|
|
94
|
+
|
|
95
|
+
chunks.each_with_index do |chunk, index|
|
|
96
|
+
futures << Concurrent::Future.execute(executor: pool) do
|
|
97
|
+
{ index: index, result: yield(chunk), error: nil }
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
{ index: index, result: nil, error: e }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Wait for all futures and collect results
|
|
104
|
+
results = futures.map(&:value)
|
|
105
|
+
pool.shutdown
|
|
106
|
+
pool.wait_for_termination(30)
|
|
107
|
+
|
|
108
|
+
results.sort_by { |r| r[:index] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def aggregate_results(results, total_vectors)
|
|
112
|
+
errors = results.select { |r| r[:error] }.map { |r| r[:error] }
|
|
113
|
+
successful = results.reject { |r| r[:error] }
|
|
114
|
+
upserted = successful.sum { |r| r.dig(:result, :upserted_count) || 0 }
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
upserted_count: upserted,
|
|
118
|
+
total_vectors: total_vectors,
|
|
119
|
+
chunks: results.size,
|
|
120
|
+
successful_chunks: successful.size,
|
|
121
|
+
errors: errors
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def aggregate_delete_results(results, total_ids)
|
|
126
|
+
errors = results.select { |r| r[:error] }.map { |r| r[:error] }
|
|
127
|
+
successful = results.reject { |r| r[:error] }
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
deleted_count: total_ids - (errors.size * (total_ids / results.size.to_f).ceil),
|
|
131
|
+
total_ids: total_ids,
|
|
132
|
+
chunks: results.size,
|
|
133
|
+
successful_chunks: successful.size,
|
|
134
|
+
errors: errors
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def merge_fetch_results(results)
|
|
139
|
+
merged = {}
|
|
140
|
+
results.each do |r|
|
|
141
|
+
next if r[:error] || r[:result].nil?
|
|
142
|
+
|
|
143
|
+
merged.merge!(r[:result])
|
|
144
|
+
end
|
|
145
|
+
merged
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/vectra/cache.rb
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Vectra
|
|
6
|
+
# Optional caching layer for frequently queried vectors
|
|
7
|
+
#
|
|
8
|
+
# Provides in-memory caching with TTL support for query results
|
|
9
|
+
# and fetched vectors to reduce database load.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# cache = Vectra::Cache.new(ttl: 300, max_size: 1000)
|
|
13
|
+
# cached_client = Vectra::CachedClient.new(client, cache: cache)
|
|
14
|
+
#
|
|
15
|
+
# # First call hits the database
|
|
16
|
+
# result1 = cached_client.query(index: 'idx', vector: vec, top_k: 10)
|
|
17
|
+
#
|
|
18
|
+
# # Second call returns cached result
|
|
19
|
+
# result2 = cached_client.query(index: 'idx', vector: vec, top_k: 10)
|
|
20
|
+
#
|
|
21
|
+
class Cache
|
|
22
|
+
DEFAULT_TTL = 300 # 5 minutes
|
|
23
|
+
DEFAULT_MAX_SIZE = 1000
|
|
24
|
+
|
|
25
|
+
attr_reader :ttl, :max_size
|
|
26
|
+
|
|
27
|
+
# Initialize cache
|
|
28
|
+
#
|
|
29
|
+
# @param ttl [Integer] time-to-live in seconds (default: 300)
|
|
30
|
+
# @param max_size [Integer] maximum cache entries (default: 1000)
|
|
31
|
+
def initialize(ttl: DEFAULT_TTL, max_size: DEFAULT_MAX_SIZE)
|
|
32
|
+
@ttl = ttl
|
|
33
|
+
@max_size = max_size
|
|
34
|
+
@store = {}
|
|
35
|
+
@timestamps = {}
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get value from cache
|
|
40
|
+
#
|
|
41
|
+
# @param key [String] cache key
|
|
42
|
+
# @return [Object, nil] cached value or nil if not found/expired
|
|
43
|
+
def get(key)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
return nil unless @store.key?(key)
|
|
46
|
+
|
|
47
|
+
if expired?(key)
|
|
48
|
+
delete_entry(key)
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@store[key]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Set value in cache
|
|
57
|
+
#
|
|
58
|
+
# @param key [String] cache key
|
|
59
|
+
# @param value [Object] value to cache
|
|
60
|
+
# @return [Object] the cached value
|
|
61
|
+
def set(key, value)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
evict_if_needed
|
|
64
|
+
@store[key] = value
|
|
65
|
+
@timestamps[key] = Time.now
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get or set value with block
|
|
71
|
+
#
|
|
72
|
+
# @param key [String] cache key
|
|
73
|
+
# @yield block to compute value if not cached
|
|
74
|
+
# @return [Object] cached or computed value
|
|
75
|
+
def fetch(key)
|
|
76
|
+
cached = get(key)
|
|
77
|
+
return cached unless cached.nil?
|
|
78
|
+
|
|
79
|
+
value = yield
|
|
80
|
+
set(key, value)
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Delete entry from cache
|
|
85
|
+
#
|
|
86
|
+
# @param key [String] cache key
|
|
87
|
+
# @return [Object, nil] deleted value
|
|
88
|
+
def delete(key)
|
|
89
|
+
@mutex.synchronize { delete_entry(key) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Clear all cache entries
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
def clear
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@store.clear
|
|
98
|
+
@timestamps.clear
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get cache statistics
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash] cache stats
|
|
105
|
+
def stats
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
{
|
|
108
|
+
size: @store.size,
|
|
109
|
+
max_size: max_size,
|
|
110
|
+
ttl: ttl,
|
|
111
|
+
keys: @store.keys
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if key exists and is not expired
|
|
117
|
+
#
|
|
118
|
+
# @param key [String] cache key
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def exist?(key)
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
return false unless @store.key?(key)
|
|
123
|
+
return false if expired?(key)
|
|
124
|
+
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def expired?(key)
|
|
132
|
+
return true unless @timestamps.key?(key)
|
|
133
|
+
|
|
134
|
+
Time.now - @timestamps[key] > ttl
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def delete_entry(key)
|
|
138
|
+
@timestamps.delete(key)
|
|
139
|
+
@store.delete(key)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def evict_if_needed
|
|
143
|
+
# Evict when at or above max_size to make room for new entry
|
|
144
|
+
return if @store.size < max_size
|
|
145
|
+
|
|
146
|
+
# Remove oldest entries (at least 10% of max_size to avoid frequent evictions)
|
|
147
|
+
entries_to_remove = [(max_size * 0.2).ceil, 1].max
|
|
148
|
+
oldest_keys = @timestamps.sort_by { |_, v| v }.first(entries_to_remove).map(&:first)
|
|
149
|
+
oldest_keys.each { |key| delete_entry(key) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Client wrapper with caching support
|
|
154
|
+
#
|
|
155
|
+
# Wraps a Vectra::Client to add transparent caching for query and fetch operations.
|
|
156
|
+
#
|
|
157
|
+
class CachedClient
|
|
158
|
+
attr_reader :client, :cache
|
|
159
|
+
|
|
160
|
+
# Initialize cached client
|
|
161
|
+
#
|
|
162
|
+
# @param client [Client] the underlying Vectra client
|
|
163
|
+
# @param cache [Cache] cache instance (creates default if nil)
|
|
164
|
+
# @param cache_queries [Boolean] whether to cache query results (default: true)
|
|
165
|
+
# @param cache_fetches [Boolean] whether to cache fetch results (default: true)
|
|
166
|
+
def initialize(client, cache: nil, cache_queries: true, cache_fetches: true)
|
|
167
|
+
@client = client
|
|
168
|
+
@cache = cache || Cache.new
|
|
169
|
+
@cache_queries = cache_queries
|
|
170
|
+
@cache_fetches = cache_fetches
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Query with caching
|
|
174
|
+
#
|
|
175
|
+
# @see Client#query
|
|
176
|
+
def query(index:, vector:, top_k: 10, namespace: nil, filter: nil, **options)
|
|
177
|
+
unless @cache_queries
|
|
178
|
+
return client.query(index: index, vector: vector, top_k: top_k,
|
|
179
|
+
namespace: namespace, filter: filter, **options)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
key = query_cache_key(index, vector, top_k, namespace, filter)
|
|
183
|
+
cache.fetch(key) do
|
|
184
|
+
client.query(index: index, vector: vector, top_k: top_k,
|
|
185
|
+
namespace: namespace, filter: filter, **options)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Fetch with caching
|
|
190
|
+
#
|
|
191
|
+
# @see Client#fetch
|
|
192
|
+
def fetch(index:, ids:, namespace: nil)
|
|
193
|
+
return client.fetch(index: index, ids: ids, namespace: namespace) unless @cache_fetches
|
|
194
|
+
|
|
195
|
+
# Check cache for each ID
|
|
196
|
+
results = {}
|
|
197
|
+
uncached_ids = []
|
|
198
|
+
|
|
199
|
+
ids.each do |id|
|
|
200
|
+
key = fetch_cache_key(index, id, namespace)
|
|
201
|
+
cached = cache.get(key)
|
|
202
|
+
if cached
|
|
203
|
+
results[id] = cached
|
|
204
|
+
else
|
|
205
|
+
uncached_ids << id
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Fetch uncached IDs
|
|
210
|
+
if uncached_ids.any?
|
|
211
|
+
fetched = client.fetch(index: index, ids: uncached_ids, namespace: namespace)
|
|
212
|
+
fetched.each do |id, vector|
|
|
213
|
+
key = fetch_cache_key(index, id, namespace)
|
|
214
|
+
cache.set(key, vector)
|
|
215
|
+
results[id] = vector
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
results
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Pass through other methods to underlying client
|
|
223
|
+
def method_missing(method, *, **, &)
|
|
224
|
+
if client.respond_to?(method)
|
|
225
|
+
client.public_send(method, *, **, &)
|
|
226
|
+
else
|
|
227
|
+
super
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def respond_to_missing?(method, include_private = false)
|
|
232
|
+
client.respond_to?(method, include_private) || super
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Invalidate cache entries for an index
|
|
236
|
+
#
|
|
237
|
+
# @param index [String] index name
|
|
238
|
+
def invalidate_index(index)
|
|
239
|
+
cache.stats[:keys].each do |key|
|
|
240
|
+
cache.delete(key) if key.start_with?("#{index}:")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Clear entire cache
|
|
245
|
+
def clear_cache
|
|
246
|
+
cache.clear
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def query_cache_key(index, vector, top_k, namespace, filter)
|
|
252
|
+
vector_hash = Digest::MD5.hexdigest(vector.to_s)[0, 16]
|
|
253
|
+
filter_hash = filter ? Digest::MD5.hexdigest(filter.to_s)[0, 8] : "nofilter"
|
|
254
|
+
"#{index}:q:#{vector_hash}:#{top_k}:#{namespace || 'default'}:#{filter_hash}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def fetch_cache_key(index, id, namespace)
|
|
258
|
+
"#{index}:f:#{id}:#{namespace || 'default'}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|