vectra-client 0.2.2 → 0.3.0

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,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
@@ -15,7 +15,8 @@ module Vectra
15
15
 
16
16
  attr_accessor :api_key, :environment, :host, :timeout, :open_timeout,
17
17
  :max_retries, :retry_delay, :logger, :pool_size, :pool_timeout,
18
- :batch_size, :instrumentation
18
+ :batch_size, :instrumentation, :cache_enabled, :cache_ttl,
19
+ :cache_max_size, :async_concurrency
19
20
 
20
21
  attr_reader :provider
21
22
 
@@ -33,6 +34,10 @@ module Vectra
33
34
  @pool_timeout = 5
34
35
  @batch_size = 100
35
36
  @instrumentation = false
37
+ @cache_enabled = false
38
+ @cache_ttl = 300
39
+ @cache_max_size = 1000
40
+ @async_concurrency = 4
36
41
  end
37
42
 
38
43
  # Set the provider
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Vectra
6
+ # Connection pool with warmup support
7
+ #
8
+ # Provides connection pooling for database providers with configurable
9
+ # pool size, timeout, and connection warmup.
10
+ #
11
+ # @example Basic usage
12
+ # pool = Vectra::Pool.new(size: 5, timeout: 5) { create_connection }
13
+ # pool.warmup(3) # Pre-create 3 connections
14
+ #
15
+ # pool.with_connection do |conn|
16
+ # conn.execute("SELECT 1")
17
+ # end
18
+ #
19
+ class Pool
20
+ class TimeoutError < Vectra::Error; end
21
+ class PoolExhaustedError < Vectra::Error; end
22
+
23
+ attr_reader :size, :timeout
24
+
25
+ # Initialize connection pool
26
+ #
27
+ # @param size [Integer] maximum pool size
28
+ # @param timeout [Integer] checkout timeout in seconds
29
+ # @yield connection factory block
30
+ def initialize(size:, timeout: 5, &block)
31
+ raise ArgumentError, "Connection factory block required" unless block_given?
32
+
33
+ @size = size
34
+ @timeout = timeout
35
+ @factory = block
36
+ @pool = Concurrent::Array.new
37
+ @checked_out = Concurrent::AtomicFixnum.new(0)
38
+ @mutex = Mutex.new
39
+ @condition = ConditionVariable.new
40
+ @shutdown = false
41
+ end
42
+
43
+ # Warmup the pool by pre-creating connections
44
+ #
45
+ # @param count [Integer] number of connections to create (default: pool size)
46
+ # @return [Integer] number of connections created
47
+ def warmup(count = nil)
48
+ count ||= size
49
+ count = [count, size].min
50
+ created = 0
51
+
52
+ count.times do
53
+ break if @pool.size >= size
54
+
55
+ conn = create_connection
56
+ if conn
57
+ @pool << conn
58
+ created += 1
59
+ end
60
+ end
61
+
62
+ created
63
+ end
64
+
65
+ # Execute block with a connection from the pool
66
+ #
67
+ # @yield [connection] the checked out connection
68
+ # @return [Object] result of the block
69
+ def with_connection
70
+ conn = checkout
71
+ begin
72
+ yield conn
73
+ ensure
74
+ checkin(conn)
75
+ end
76
+ end
77
+
78
+ # Checkout a connection from the pool
79
+ #
80
+ # @return [Object] a connection
81
+ # @raise [TimeoutError] if checkout times out
82
+ # @raise [PoolExhaustedError] if pool is exhausted
83
+ def checkout
84
+ raise PoolExhaustedError, "Pool has been shutdown" if @shutdown
85
+
86
+ deadline = Time.now + timeout
87
+
88
+ @mutex.synchronize do
89
+ loop do
90
+ # Try to get an existing connection
91
+ conn = @pool.pop
92
+ if conn
93
+ @checked_out.increment
94
+ return conn if healthy?(conn)
95
+
96
+ # Connection is unhealthy, discard and try again
97
+ close_connection(conn)
98
+ next
99
+ end
100
+
101
+ # Try to create a new connection if under limit
102
+ if @checked_out.value + @pool.size < size
103
+ conn = create_connection
104
+ if conn
105
+ @checked_out.increment
106
+ return conn
107
+ end
108
+ end
109
+
110
+ # Wait for a connection to be returned
111
+ remaining = deadline - Time.now
112
+ raise TimeoutError, "Connection checkout timed out after #{timeout}s" if remaining <= 0
113
+
114
+ @condition.wait(@mutex, remaining)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Return a connection to the pool
120
+ #
121
+ # @param connection [Object] connection to return
122
+ def checkin(connection)
123
+ return if @shutdown
124
+
125
+ @mutex.synchronize do
126
+ @checked_out.decrement
127
+ if healthy?(connection) && @pool.size < size
128
+ @pool << connection
129
+ else
130
+ close_connection(connection)
131
+ end
132
+ @condition.signal
133
+ end
134
+ end
135
+
136
+ # Shutdown the pool, closing all connections
137
+ #
138
+ # @return [void]
139
+ def shutdown
140
+ @shutdown = true
141
+ @mutex.synchronize do
142
+ while (conn = @pool.pop)
143
+ close_connection(conn)
144
+ end
145
+ end
146
+ end
147
+
148
+ # Get pool statistics
149
+ #
150
+ # @return [Hash] pool stats
151
+ def stats
152
+ {
153
+ size: size,
154
+ available: @pool.size,
155
+ checked_out: @checked_out.value,
156
+ total_created: @pool.size + @checked_out.value,
157
+ shutdown: @shutdown
158
+ }
159
+ end
160
+
161
+ # Check if pool is healthy (public method)
162
+ #
163
+ # @return [Boolean]
164
+ def pool_healthy?
165
+ !@shutdown && (@pool.size + @checked_out.value).positive?
166
+ end
167
+
168
+ private
169
+
170
+ # Internal health check for individual connections
171
+ def healthy?(conn)
172
+ return false if conn.nil?
173
+ return true unless conn.respond_to?(:status)
174
+
175
+ # For PG connections, check status. Otherwise assume healthy.
176
+ if defined?(PG::CONNECTION_OK)
177
+ conn.status == PG::CONNECTION_OK
178
+ else
179
+ # If PG not loaded, assume connection is healthy if it exists
180
+ true
181
+ end
182
+ rescue StandardError
183
+ false
184
+ end
185
+
186
+ def create_connection
187
+ @factory.call
188
+ rescue StandardError => e
189
+ Vectra.configuration.logger&.error("Pool: Failed to create connection: #{e.message}")
190
+ nil
191
+ end
192
+
193
+ def close_connection(conn)
194
+ conn.close if conn.respond_to?(:close)
195
+ rescue StandardError => e
196
+ Vectra.configuration.logger&.warn("Pool: Error closing connection: #{e.message}")
197
+ end
198
+ end
199
+
200
+ # Pooled connection module for pgvector
201
+ module PooledConnection
202
+ # Get a pooled connection
203
+ #
204
+ # @return [Pool] connection pool
205
+ def connection_pool
206
+ @connection_pool ||= create_pool
207
+ end
208
+
209
+ # Warmup the connection pool
210
+ #
211
+ # @param count [Integer] number of connections to pre-create
212
+ # @return [Integer] connections created
213
+ def warmup_pool(count = nil)
214
+ connection_pool.warmup(count)
215
+ end
216
+
217
+ # Execute with pooled connection
218
+ #
219
+ # @yield [connection] database connection
220
+ def with_pooled_connection(&)
221
+ connection_pool.with_connection(&)
222
+ end
223
+
224
+ # Shutdown the connection pool
225
+ def shutdown_pool
226
+ @connection_pool&.shutdown
227
+ @connection_pool = nil
228
+ end
229
+
230
+ # Get pool statistics
231
+ #
232
+ # @return [Hash]
233
+ def pool_stats
234
+ return { status: "not_initialized" } unless @connection_pool
235
+
236
+ connection_pool.stats
237
+ end
238
+
239
+ private
240
+
241
+ def create_pool
242
+ pool_size = config.pool_size || 5
243
+ pool_timeout = config.pool_timeout || 5
244
+
245
+ Pool.new(size: pool_size, timeout: pool_timeout) do
246
+ create_raw_connection
247
+ end
248
+ end
249
+
250
+ def create_raw_connection
251
+ require "pg"
252
+ conn_params = parse_connection_params
253
+ PG.connect(conn_params)
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ # Streaming query results for large datasets
5
+ #
6
+ # Provides lazy enumeration over query results with automatic pagination,
7
+ # reducing memory usage for large result sets.
8
+ #
9
+ # @example Stream through results
10
+ # stream = Vectra::Streaming.new(client)
11
+ # stream.query_each(index: 'my-index', vector: query_vec, total: 1000) do |match|
12
+ # process(match)
13
+ # end
14
+ #
15
+ # @example As lazy enumerator
16
+ # results = stream.query_stream(index: 'my-index', vector: query_vec, total: 1000)
17
+ # results.take(50).each { |m| puts m.id }
18
+ #
19
+ class Streaming
20
+ DEFAULT_PAGE_SIZE = 100
21
+
22
+ attr_reader :client, :page_size
23
+
24
+ # Initialize streaming query handler
25
+ #
26
+ # @param client [Client] the Vectra client
27
+ # @param page_size [Integer] results per page (default: 100)
28
+ def initialize(client, page_size: DEFAULT_PAGE_SIZE)
29
+ @client = client
30
+ @page_size = [page_size, 1].max
31
+ end
32
+
33
+ # Stream query results with a block
34
+ #
35
+ # @param index [String] the index name
36
+ # @param vector [Array<Float>] query vector
37
+ # @param total [Integer] total results to fetch
38
+ # @param namespace [String, nil] optional namespace
39
+ # @param filter [Hash, nil] metadata filter
40
+ # @yield [Match] each match result
41
+ # @return [Integer] total results yielded
42
+ def query_each(index:, vector:, total:, namespace: nil, filter: nil, &block)
43
+ return 0 unless block_given?
44
+
45
+ count = 0
46
+ query_stream(index: index, vector: vector, total: total, namespace: namespace, filter: filter).each do |match|
47
+ block.call(match)
48
+ count += 1
49
+ end
50
+ count
51
+ end
52
+
53
+ # Create a lazy enumerator for streaming results
54
+ #
55
+ # @param index [String] the index name
56
+ # @param vector [Array<Float>] query vector
57
+ # @param total [Integer] total results to fetch
58
+ # @param namespace [String, nil] optional namespace
59
+ # @param filter [Hash, nil] metadata filter
60
+ # @return [Enumerator::Lazy] lazy enumerator of results
61
+ def query_stream(index:, vector:, total:, namespace: nil, filter: nil)
62
+ Enumerator.new do |yielder|
63
+ fetched = 0
64
+ seen_ids = Set.new
65
+
66
+ while fetched < total
67
+ batch_size = [page_size, total - fetched].min
68
+
69
+ result = client.query(
70
+ index: index,
71
+ vector: vector,
72
+ top_k: batch_size,
73
+ namespace: namespace,
74
+ filter: filter,
75
+ include_metadata: true
76
+ )
77
+
78
+ break if result.empty?
79
+
80
+ result.each do |match|
81
+ # Skip duplicates (some providers may return overlapping results)
82
+ next if seen_ids.include?(match.id)
83
+
84
+ seen_ids.add(match.id)
85
+ yielder << match
86
+ fetched += 1
87
+
88
+ break if fetched >= total
89
+ end
90
+
91
+ # If we got fewer results than requested, we've exhausted the index
92
+ break if result.size < batch_size
93
+ end
94
+ end.lazy
95
+ end
96
+
97
+ # Scan all vectors in an index (provider-dependent)
98
+ #
99
+ # @param index [String] the index name
100
+ # @param namespace [String, nil] optional namespace
101
+ # @param batch_size [Integer] IDs per batch
102
+ # @yield [Vector] each vector
103
+ # @return [Integer] total vectors scanned
104
+ # @note Not all providers support efficient scanning
105
+ def scan_all(index:, namespace: nil, batch_size: 1000)
106
+ return 0 unless block_given?
107
+
108
+ count = 0
109
+ offset = 0
110
+
111
+ loop do
112
+ # This is a simplified scan - actual implementation depends on provider
113
+ stats = client.stats(index: index, namespace: namespace)
114
+ total = stats[:total_vector_count] || 0
115
+
116
+ break if offset >= total
117
+
118
+ # Fetch IDs in batches (this is provider-specific)
119
+ # For now, we return what we can
120
+ break if offset.positive? # Only one iteration for basic implementation
121
+
122
+ offset += batch_size
123
+ count = total
124
+ end
125
+
126
+ count
127
+ end
128
+ end
129
+
130
+ # Streaming result wrapper with additional metadata
131
+ class StreamingResult
132
+ include Enumerable
133
+
134
+ attr_reader :enumerator, :metadata
135
+
136
+ def initialize(enumerator, metadata = {})
137
+ @enumerator = enumerator
138
+ @metadata = metadata
139
+ end
140
+
141
+ def each(&)
142
+ enumerator.each(&)
143
+ end
144
+
145
+ def take(n)
146
+ enumerator.take(n)
147
+ end
148
+
149
+ def to_a
150
+ enumerator.to_a
151
+ end
152
+ end
153
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vectra
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/vectra.rb CHANGED
@@ -7,6 +7,10 @@ require_relative "vectra/vector"
7
7
  require_relative "vectra/query_result"
8
8
  require_relative "vectra/instrumentation"
9
9
  require_relative "vectra/retry"
10
+ require_relative "vectra/batch"
11
+ require_relative "vectra/streaming"
12
+ require_relative "vectra/cache"
13
+ require_relative "vectra/pool"
10
14
  require_relative "vectra/active_record"
11
15
  require_relative "vectra/providers/base"
12
16
  require_relative "vectra/providers/pinecone"