activerecord-turbopuffer-adapter 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23fa3cc80f986e56ce18f779fb0d108fc441bc0eb38bc7f8b3eeb23d25ae1c7b
4
- data.tar.gz: 1f888b8b3bf5238a24f32d76bfcfa5fe71ef433a3b85c12db4b0f96d36c888f5
3
+ metadata.gz: 69922615a71c6eefbe25e734208f52099110685db3ad28073dd79e284ec3381b
4
+ data.tar.gz: 47e713a1a9336e51dcb6e446e23d3364f2bcf8f23ff944a0b52e2a7d4e49acbf
5
5
  SHA512:
6
- metadata.gz: c969a1e7195c56421d923e8d711f81c71028fe8b7ccbdb3b774ccb56671db31b75f5e9a9803bd28886ef503cf7c6477171f4175f799cbe025d65fba41e9a0476
7
- data.tar.gz: 96966fdeb4d6ebeebaa15d717404359947b5afee6c753b19e92e7fe9fe50ead8e708e7728b654e72ef7706042f7dca34bfbec7cdbf3f278e04fde7449b9b271d
6
+ metadata.gz: 7b366354338fd3d15e689340dbddf279ebf4b4d3823412ae1ef95d21eca48d1a06b492738b1aa21c9abb87e77906e2d3f07b67e71f80b61241e1f3143126946d
7
+ data.tar.gz: fe83fd43d546c5e07e19fcc38a14760b2810955f5e3398ce4b4895c19280fa3c77dc88874a6bb771ff5572fd90a6a9ebd13bb0563c2a69fa40de5375b6fa34fa
data/README.md CHANGED
@@ -105,6 +105,8 @@ Document.find("018f...")
105
105
  Document.rank_by("vector", "ANN", query_vector).limit(10)
106
106
  Document.rank_by("text", "BM25", "quick walrus").limit(10)
107
107
 
108
+ Document.consistency(:eventual).where(published: true)
109
+
108
110
  Document
109
111
  .where(published: true)
110
112
  .rank_by(["Sum", [
@@ -116,6 +118,26 @@ Document
116
118
 
117
119
  > Note that turbopuffer has a limit on the maximum number of documents returned (https://turbopuffer.com/docs/query#param-limit), so Document.all.to_a etc. will only return at most 10,000 items
118
120
 
121
+ > Eventual reads are cheaper but may not include writes from the last few seconds. Set a default with `turbopuffer_consistency "eventual"` in a model or `consistency: eventual` in `database.yml`. Strong is the default.
122
+
123
+ ### Errors
124
+
125
+ Turbopuffer errors are raised as the ActiveRecord exceptions a Rails app already handles.
126
+
127
+ ```ruby
128
+ begin
129
+ Document.where(published: true).to_a
130
+ rescue ActiveRecord::ConnectionFailed
131
+ # turbopuffer is unreachable
132
+ rescue ActiveRecord::StatementTimeout
133
+ # the request timed out
134
+ rescue ActiveRecord::DatabaseConnectionError
135
+ # the API key is invalid
136
+ rescue ActiveRecord::StatementInvalid => e
137
+ e.cause # => the underlying Turbopuffer::Errors::APIError, e.g. a bad request or rate limit
138
+ end
139
+ ```
140
+
119
141
  ### Using alongside Postgres
120
142
 
121
143
  While something of a lark, aspirationally the idea of this gem is to make turbopuffer conveniently usable as the primary db in a Rails app. In practice, however, using turbopuffer alongside a traditional primary db (such as postgres) is a supported, potentially more practical solution.
@@ -225,6 +225,14 @@ module ActiveRecord
225
225
  0
226
226
  end
227
227
 
228
+ def consistency_for(query)
229
+ model = ::Turbopuffer::ActiveRecord::Schema::Model.for_table(query.namespace)
230
+ level = query.consistency || model.turbopuffer_consistency || @config[:consistency]
231
+ return if level.nil?
232
+
233
+ { level: ::Turbopuffer::ActiveRecord::Schema::Model.validate_consistency(level).to_sym }
234
+ end
235
+
228
236
  def turbopuffer_aggregate(namespace, query)
229
237
  aggregate_alias, aggregate = query.aggregate_by
230
238
 
@@ -233,6 +241,8 @@ module ActiveRecord
233
241
  }
234
242
 
235
243
  tpuf_query_args[:filters] = query.filters if query.filters
244
+ consistency = consistency_for(query)
245
+ tpuf_query_args[:consistency] = consistency if consistency
236
246
 
237
247
  if query.group_by.present?
238
248
  tpuf_query_args[:group_by] = query.group_by
@@ -269,6 +279,8 @@ module ActiveRecord
269
279
 
270
280
  tpuf_query_args[:rank_by] = query.rank_by.first if query.rank_by.present?
271
281
  tpuf_query_args[:filters] = query.filters if query.filters
282
+ consistency = consistency_for(query)
283
+ tpuf_query_args[:consistency] = consistency if consistency
272
284
 
273
285
  tpuf_result = namespace.query(tpuf_query_args)
274
286
 
@@ -351,6 +363,19 @@ module ActiveRecord
351
363
  def reconnect
352
364
  connect
353
365
  end
366
+
367
+ def translate_exception(exception, message:, sql:, binds:)
368
+ case exception
369
+ when Turbopuffer::Errors::APITimeoutError
370
+ ActiveRecord::StatementTimeout.new(message, sql: sql, binds: binds, connection_pool: @pool)
371
+ when Turbopuffer::Errors::APIConnectionError
372
+ ActiveRecord::ConnectionFailed.new(message, sql: sql, binds: binds, connection_pool: @pool)
373
+ when Turbopuffer::Errors::AuthenticationError, Turbopuffer::Errors::PermissionDeniedError
374
+ ActiveRecord::DatabaseConnectionError.new(message)
375
+ else
376
+ super
377
+ end
378
+ end
354
379
  end
355
380
  end
356
381
  end
@@ -10,7 +10,8 @@ module Arel::Visitors
10
10
  rank_by: @rank_by,
11
11
  include_attributes: @include_attributes,
12
12
  aggregate_by: @aggregate_by,
13
- upsert_rows: @upsert_rows
13
+ upsert_rows: @upsert_rows,
14
+ consistency: @consistency
14
15
  }
15
16
  end
16
17
  def to_s = JSON.generate(to_h)
@@ -19,10 +20,11 @@ module Arel::Visitors
19
20
  def eql?(other) = other.is_a?(self.class) && to_h == other.to_h
20
21
  alias == eql?
21
22
 
22
- attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by
23
+ attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by,
24
+ :consistency
23
25
 
24
- def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil,
25
- include_attributes: nil, upsert_rows: nil, aggregate_by: nil, group_by: nil)
26
+ def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil, include_attributes: nil,
27
+ upsert_rows: nil, aggregate_by: nil, group_by: nil, consistency: nil)
26
28
  @op = op
27
29
  @namespace = namespace
28
30
  @filters = filters
@@ -32,6 +34,7 @@ module Arel::Visitors
32
34
  @aggregate_by = aggregate_by
33
35
  @group_by = group_by
34
36
  @upsert_rows = upsert_rows
37
+ @consistency = consistency
35
38
  end
36
39
  end
37
40
 
@@ -120,10 +123,18 @@ module Arel::Visitors
120
123
  rank_by: o.orders.map { |ord| visit(ord) },
121
124
  include_attributes: attributes.flat_map { |p| visit(p) },
122
125
  group_by: core.groups.map { |g| visit(g) },
123
- aggregate_by: aggregate && visit(aggregate)
126
+ aggregate_by: aggregate && visit(aggregate),
127
+ consistency: consistency_hint(core.optimizer_hints)
124
128
  )
125
129
  end
126
130
 
131
+ def consistency_hint(hints)
132
+ return unless hints
133
+
134
+ hint = hints.expr.reverse.find { |h| h.start_with?("consistency=") }
135
+ hint&.delete_prefix("consistency=")
136
+ end
137
+
127
138
  def visit_Arel_Nodes_InsertStatement(o)
128
139
  raise NotImplementedError, "INSERT ... SELECT" if o.select
129
140
 
@@ -5,10 +5,12 @@ module Turbopuffer
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  DISTANCE_METRICS = [ "cosine_distance", "euclidean_squared" ].freeze
8
+ CONSISTENCY_LEVELS = [ "strong", "eventual" ].freeze
8
9
 
9
10
  included do
10
11
  class_attribute :turbopuffer_attributes, instance_accessor: false, default: [].freeze
11
12
  class_attribute :_turbopuffer_distance_metric, instance_accessor: false, default: "cosine_distance"
13
+ class_attribute :_turbopuffer_consistency, instance_accessor: false, default: nil
12
14
  end
13
15
 
14
16
  class << self
@@ -32,6 +34,17 @@ module Turbopuffer
32
34
 
33
35
  model
34
36
  end
37
+
38
+ def validate_consistency(level)
39
+ level = level.to_s
40
+
41
+ unless CONSISTENCY_LEVELS.include?(level)
42
+ raise ArgumentError,
43
+ "consistency must be one of #{CONSISTENCY_LEVELS.join(", ")}, got #{level.inspect}"
44
+ end
45
+
46
+ level
47
+ end
35
48
  end
36
49
 
37
50
  class_methods do
@@ -50,6 +63,8 @@ module Turbopuffer
50
63
  def turbopuffer_distance_metric(value = nil)
51
64
  return _turbopuffer_distance_metric if value.nil?
52
65
 
66
+ value = value.to_s
67
+
53
68
  unless DISTANCE_METRICS.include?(value)
54
69
  raise ArgumentError,
55
70
  "distance metric must be one of #{DISTANCE_METRICS.join(", ")}, got #{value.inspect}"
@@ -67,6 +82,16 @@ module Turbopuffer
67
82
  ::Turbopuffer::ActiveRecord::Glob.new(pattern, case_sensitive:)
68
83
  end
69
84
 
85
+ def consistency(level)
86
+ optimizer_hints("consistency=#{Model.validate_consistency(level)}")
87
+ end
88
+
89
+ def turbopuffer_consistency(value = nil)
90
+ return _turbopuffer_consistency if value.nil?
91
+
92
+ self._turbopuffer_consistency = Model.validate_consistency(value)
93
+ end
94
+
70
95
  def predicate_builder
71
96
  @predicate_builder ||= super.tap do |builder|
72
97
  builder.register_handler(::Regexp, lambda { |attribute, regexp|
@@ -1,5 +1,5 @@
1
1
  module Turbopuffer
2
2
  module ActiveRecord
3
- VERSION = "0.1.2"
3
+ VERSION = "0.1.4"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-turbopuffer-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Monette
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-20 00:00:00.000000000 Z
10
+ date: 2026-09-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord