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.
@@ -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
@@ -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