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,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,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ # Proactive rate limiter using token bucket algorithm
5
+ #
6
+ # Throttles requests BEFORE sending to prevent rate limit errors from providers.
7
+ # Uses token bucket algorithm for smooth rate limiting with burst support.
8
+ #
9
+ # @example Basic usage
10
+ # limiter = Vectra::RateLimiter.new(
11
+ # requests_per_second: 10,
12
+ # burst_size: 20
13
+ # )
14
+ #
15
+ # limiter.acquire do
16
+ # client.query(...)
17
+ # end
18
+ #
19
+ # @example With client wrapper
20
+ # client = Vectra::RateLimitedClient.new(
21
+ # Vectra::Client.new(...),
22
+ # requests_per_second: 10
23
+ # )
24
+ # client.query(...) # Automatically rate limited
25
+ #
26
+ class RateLimiter
27
+ class RateLimitExceededError < Vectra::Error
28
+ attr_reader :wait_time
29
+
30
+ def initialize(wait_time:)
31
+ @wait_time = wait_time
32
+ super("Rate limit exceeded. Retry after #{wait_time.round(2)} seconds")
33
+ end
34
+ end
35
+
36
+ attr_reader :requests_per_second, :burst_size
37
+
38
+ # Initialize rate limiter
39
+ #
40
+ # @param requests_per_second [Float] Sustained request rate
41
+ # @param burst_size [Integer] Maximum burst capacity (default: 2x RPS)
42
+ def initialize(requests_per_second:, burst_size: nil)
43
+ @requests_per_second = requests_per_second.to_f
44
+ @burst_size = burst_size || (requests_per_second * 2).to_i
45
+ @tokens = @burst_size.to_f
46
+ @last_refill = Time.now
47
+ @mutex = Mutex.new
48
+ end
49
+
50
+ # Acquire a token and execute block
51
+ #
52
+ # @param wait [Boolean] Wait for token if not available (default: true)
53
+ # @param timeout [Float] Maximum wait time in seconds (default: 30)
54
+ # @yield Block to execute after acquiring token
55
+ # @return [Object] Result of block
56
+ # @raise [RateLimitExceededError] If wait is false and no token available
57
+ def acquire(wait: true, timeout: 30, &)
58
+ acquired = try_acquire(wait: wait, timeout: timeout)
59
+
60
+ unless acquired
61
+ wait_time = time_until_token
62
+ raise RateLimitExceededError.new(wait_time: wait_time)
63
+ end
64
+
65
+ yield
66
+ end
67
+
68
+ # Try to acquire a token without blocking
69
+ #
70
+ # @return [Boolean] true if token acquired
71
+ def try_acquire(wait: false, timeout: 30)
72
+ deadline = Time.now + timeout
73
+
74
+ loop do
75
+ @mutex.synchronize do
76
+ refill_tokens
77
+ if @tokens >= 1
78
+ @tokens -= 1
79
+ return true
80
+ end
81
+ end
82
+
83
+ return false unless wait
84
+ return false if Time.now >= deadline
85
+
86
+ # Wait a bit before retrying
87
+ sleep([time_until_token, 0.1].min)
88
+ end
89
+ end
90
+
91
+ # Get current token count
92
+ #
93
+ # @return [Float]
94
+ def available_tokens
95
+ @mutex.synchronize do
96
+ refill_tokens
97
+ @tokens
98
+ end
99
+ end
100
+
101
+ # Get time until next token is available
102
+ #
103
+ # @return [Float] Seconds until next token
104
+ def time_until_token
105
+ @mutex.synchronize do
106
+ refill_tokens
107
+ return 0 if @tokens >= 1
108
+
109
+ tokens_needed = 1 - @tokens
110
+ tokens_needed / @requests_per_second
111
+ end
112
+ end
113
+
114
+ # Get rate limiter statistics
115
+ #
116
+ # @return [Hash]
117
+ def stats
118
+ @mutex.synchronize do
119
+ refill_tokens
120
+ {
121
+ requests_per_second: @requests_per_second,
122
+ burst_size: @burst_size,
123
+ available_tokens: @tokens.round(2),
124
+ time_until_token: @tokens >= 1 ? 0 : ((1 - @tokens) / @requests_per_second).round(3)
125
+ }
126
+ end
127
+ end
128
+
129
+ # Reset the rate limiter to full capacity
130
+ #
131
+ # @return [void]
132
+ def reset!
133
+ @mutex.synchronize do
134
+ @tokens = @burst_size.to_f
135
+ @last_refill = Time.now
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def refill_tokens
142
+ now = Time.now
143
+ elapsed = now - @last_refill
144
+ @last_refill = now
145
+
146
+ # Add tokens based on elapsed time
147
+ new_tokens = elapsed * @requests_per_second
148
+ @tokens = [@tokens + new_tokens, @burst_size.to_f].min
149
+ end
150
+ end
151
+
152
+ # Client wrapper with automatic rate limiting
153
+ #
154
+ # @example
155
+ # client = Vectra::RateLimitedClient.new(
156
+ # Vectra::Client.new(provider: :pinecone, ...),
157
+ # requests_per_second: 10,
158
+ # burst_size: 20
159
+ # )
160
+ #
161
+ # client.query(...) # Automatically waits if rate limited
162
+ #
163
+ class RateLimitedClient
164
+ RATE_LIMITED_METHODS = [:upsert, :query, :fetch, :update, :delete].freeze
165
+
166
+ attr_reader :client, :limiter
167
+
168
+ # Initialize rate limited client
169
+ #
170
+ # @param client [Client] The underlying Vectra client
171
+ # @param requests_per_second [Float] Request rate limit
172
+ # @param burst_size [Integer] Burst capacity
173
+ # @param wait [Boolean] Wait for rate limit (default: true)
174
+ def initialize(client, requests_per_second:, burst_size: nil, wait: true)
175
+ @client = client
176
+ @limiter = RateLimiter.new(
177
+ requests_per_second: requests_per_second,
178
+ burst_size: burst_size
179
+ )
180
+ @wait = wait
181
+ end
182
+
183
+ # Rate-limited upsert
184
+ def upsert(...)
185
+ with_rate_limit { client.upsert(...) }
186
+ end
187
+
188
+ # Rate-limited query
189
+ def query(...)
190
+ with_rate_limit { client.query(...) }
191
+ end
192
+
193
+ # Rate-limited fetch
194
+ def fetch(...)
195
+ with_rate_limit { client.fetch(...) }
196
+ end
197
+
198
+ # Rate-limited update
199
+ def update(...)
200
+ with_rate_limit { client.update(...) }
201
+ end
202
+
203
+ # Rate-limited delete
204
+ def delete(...)
205
+ with_rate_limit { client.delete(...) }
206
+ end
207
+
208
+ # Pass through other methods without rate limiting
209
+ def method_missing(method, *, **, &)
210
+ if client.respond_to?(method)
211
+ client.public_send(method, *, **, &)
212
+ else
213
+ super
214
+ end
215
+ end
216
+
217
+ def respond_to_missing?(method, include_private = false)
218
+ client.respond_to?(method, include_private) || super
219
+ end
220
+
221
+ # Get rate limiter stats
222
+ #
223
+ # @return [Hash]
224
+ def rate_limit_stats
225
+ limiter.stats
226
+ end
227
+
228
+ private
229
+
230
+ def with_rate_limit(&)
231
+ limiter.acquire(wait: @wait, &)
232
+ end
233
+ end
234
+
235
+ # Per-provider rate limiter registry
236
+ #
237
+ # @example
238
+ # # Configure rate limits per provider
239
+ # Vectra::RateLimiterRegistry.configure(:pinecone, requests_per_second: 100)
240
+ # Vectra::RateLimiterRegistry.configure(:qdrant, requests_per_second: 50)
241
+ #
242
+ # # Get limiter for provider
243
+ # limiter = Vectra::RateLimiterRegistry[:pinecone]
244
+ # limiter.acquire { ... }
245
+ #
246
+ module RateLimiterRegistry
247
+ class << self
248
+ # Configure rate limiter for provider
249
+ #
250
+ # @param provider [Symbol] Provider name
251
+ # @param requests_per_second [Float] Request rate
252
+ # @param burst_size [Integer] Burst capacity
253
+ # @return [RateLimiter]
254
+ def configure(provider, requests_per_second:, burst_size: nil)
255
+ limiters[provider.to_sym] = RateLimiter.new(
256
+ requests_per_second: requests_per_second,
257
+ burst_size: burst_size
258
+ )
259
+ end
260
+
261
+ # Get limiter for provider
262
+ #
263
+ # @param provider [Symbol] Provider name
264
+ # @return [RateLimiter, nil]
265
+ def [](provider)
266
+ limiters[provider.to_sym]
267
+ end
268
+
269
+ # Get all configured limiters
270
+ #
271
+ # @return [Hash<Symbol, RateLimiter>]
272
+ def all
273
+ limiters.dup
274
+ end
275
+
276
+ # Get stats for all limiters
277
+ #
278
+ # @return [Hash<Symbol, Hash>]
279
+ def stats
280
+ limiters.transform_values(&:stats)
281
+ end
282
+
283
+ # Reset all limiters
284
+ #
285
+ # @return [void]
286
+ def reset_all!
287
+ limiters.each_value(&:reset!)
288
+ end
289
+
290
+ # Clear all limiters
291
+ #
292
+ # @return [void]
293
+ def clear!
294
+ @limiters = {}
295
+ end
296
+
297
+ private
298
+
299
+ def limiters
300
+ @limiters ||= {}
301
+ end
302
+ end
303
+ end
304
+ 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.1"
5
5
  end