search-engine-for-typesense 30.8.24 → 30.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db1ad7dbacfe019861ce7dbe7f3511326d67f0afc8bbf22ed2929cdb300797ad
4
- data.tar.gz: 5bfafebc3623ee781a1c519c799621bfb8ca62b19f21e6370e79d4d165bc3999
3
+ metadata.gz: 2f5d1b6315a75f241c80d16c1f69727fed0ceef2edabb8ce774d33c348756a59
4
+ data.tar.gz: fbb2364ac8afc372ff0bbcb8f70174874ab190e226f6d08e1e747b018be8259b
5
5
  SHA512:
6
- metadata.gz: b1ce3108ba2a8ef685803cf06264e88a9565dfe7e640f983465f198a03ba2f5b4cf059555c00b2e1016fa38f49aadb6be6bc7bcc538b5564a46c922e103f6db5
7
- data.tar.gz: 9a2ddb0b81c46d8c81ce06e622f4b16adf1f81f50a4f4ccf2ff756c3d2542cf8d90111d31b7412f77e88dd441d7429106779457033e30bfc0c8d6b12e169342f
6
+ metadata.gz: 1db71818517bd5dd234a43ebfe63f1b9affa33b4fcb0f6a15914cb6bcd55a3a6844ba8a87e68ead1bbc81a3a87dbe3dba2d845f9ba8c4da6d3251ed877755c5b
7
+ data.tar.gz: 56a54cdd5a7a0fdf3566c021788ba9f4d54aa9311f7df508b8b9e99c5d7010c9ecd26cf2d83cc219625a48d7738883e03c49360c9d7c4d256f4fc48d173e315b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 30.9.1
4
+
5
+ - Serialize PostgreSQL delivery discovery across schedulers and workers and check delivery membership
6
+ by event and target. Hosts can enable checkpointed scans to bound discovery on large outboxes.
7
+ - Execute advisory locks and delivery cutover polls on the database when Rails query caching is enabled.
8
+
9
+ ## 30.9.0
10
+
11
+ - Qualify geo/vector search and native caching against Typesense 30.2 while keeping the gem major
12
+ version aligned with the Typesense major version.
13
+ - Expose model-level geo/vector query methods, preserve external hybrid weights and historical query
14
+ boundaries, and sort the active vector query with Typesense's native distance token.
15
+ - Validate finite real vectors, declared field types/dimensions and integer options before execution.
16
+ Preserve backend vector distance on flat, grouped and multi-search hits.
17
+ - Redact scientific-notation vector components and forward single/multi-search cache options through
18
+ the Ruby SDK's request parameters.
19
+ - Preserve caller-owned history strings and IDs, reject invalid scalar conversions consistently, and
20
+ mask malformed vector values. Align single-search adapters, telemetry and shell-quoted cURL previews
21
+ with GET requests and URL cache controls.
22
+ - Keep stale-deletion estimates uncached so maintenance previews observe current documents.
23
+ - Add database-free query examples and isolated Typesense 30.2 integration checks, including scoped-key
24
+ cache isolation and opt-in model-backed search. Upgrade older servers before using scoped-key caching.
25
+
3
26
  ## 30.8.24
4
27
 
5
28
  - Adopt `{typesense_major}.{gem_minor}.{gem_patch}` versioning, omitting the Typesense minor component.
data/README.md CHANGED
@@ -8,15 +8,18 @@ Mountless Rails::Engine for [Typesense](https://typesense.org). Expressive Relat
8
8
 
9
9
  ## Versioning
10
10
 
11
- Versions use `{typesense_major}.{gem_minor}.{gem_patch}`. The first component identifies the targeted Typesense server major version; the remaining components track gem enhancements and fixes independently of Typesense minor releases.
11
+ Versions use `{typesense_major}.{gem_minor}.{gem_patch}`. The first component identifies the targeted
12
+ Typesense server major version; the remaining components track gem enhancements and fixes independently
13
+ of Typesense minor releases.
12
14
 
13
- Example: `30.8.24` targets Typesense `30.x`. Typesense minor versions are not encoded in the gem version.
15
+ Gem `30.9.0` targets Typesense **30.x** and is tested against **30.2**. Typesense minor versions are not
16
+ encoded in the gem version.
14
17
 
15
18
  ## Quickstart
16
19
 
17
20
  ```ruby
18
21
  # Gemfile
19
- gem "search-engine-for-typesense"
22
+ gem "search-engine-for-typesense", "~> 30.9"
20
23
  ```
21
24
 
22
25
  ```ruby
@@ -180,8 +183,56 @@ SearchEngine::Venue
180
183
  # Access geo distance on results (present when order_geo is used)
181
184
  result = SearchEngine::Venue.all.order_geo(:location, from: { lat: 54.69, lng: 25.28 }).execute
182
185
  result.hits.first.geo_distance_meters # => { "location" => 1234 }
186
+
187
+ # External vectors: supply embeddings generated by your application
188
+ class SearchEngine::Article < SearchEngine::Base
189
+ collection :articles
190
+ identify_by :id
191
+ attribute :title, :string
192
+ embedding :embedding, suffix: false, num_dim: 3
193
+ query_by :title
194
+ end
195
+
196
+ result = SearchEngine::Article
197
+ .search('chair')
198
+ .vector_search(query: [1, 0, 0], alpha: 0.8, k: 20)
199
+ .order(vector_distance: :asc)
200
+ .execute
201
+ result.hits.first.vector_distance # Exact backend distance, including 0
202
+ SearchEngine::Article.find_similar('article-42', k: 20).to_a
203
+ ```
204
+
205
+ `where_geo`, `order_geo`, `order_eval`, `vector_search` and `find_similar` work directly on models
206
+ and on relations. They build lazy queries. Vector distance ordering uses the active query's native
207
+ `_vector_distance` sort; it does not generate another embedding. The metadata reader is present when
208
+ the backend supplies a distance, including grouped and multi-search hits. It takes precedence over
209
+ a same-named document reader; the original document remains in `result.raw`.
210
+
211
+ Vectors must contain finite real numbers and match declared dimensions. Integer options (`k`, `ef`,
212
+ `flat_search_cutoff`) reject fractional values and accept integer strings. Historical queries preserve
213
+ commas, parentheses and brackets; colons, backticks, control characters and surrounding whitespace
214
+ raise `InvalidVectorQuery`. Similarity IDs reject commas, colons, parentheses, control characters and
215
+ surrounding whitespace. Weight sums keep the configurable wrapper tolerance (default `0.01`), but
216
+ Typesense 30.1/30.2 require a sum of 1.0; a server rejection propagates as `Errors::Api`.
217
+
218
+ Typesense **30.2** is the tested server baseline. Geo, external vectors, auto-embedded hybrid search,
219
+ historical queries and native caching are checked against that version. Earlier 30.1 qualification
220
+ exposed the scoped-cache isolation defect described below; upgrade before using scoped-key caching.
221
+ See the [vector guide](https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30/vector-search)
222
+ for auto-embedding declarations and query modes.
223
+
224
+ Run the database-free examples from the gem root:
225
+
226
+ ```bash
227
+ bundle exec ruby examples/demo_shop/bin/query_smoke
183
228
  ```
184
229
 
230
+ This compiles geo/vector requests and checks metadata fixtures without HTTP requests or model downloads.
231
+ See [the demo instructions](examples/demo_shop/README.md#query-examples) for explicitly isolated live execution,
232
+ including highlighting through `Client#search` and `hit.snippet_for`.
233
+ CI uses a disposable `typesense/typesense:30.2` service for geo, vector, cache and live CLI checks,
234
+ including the built-in embedding model. The default unit suite runs separately without integration ENV.
235
+
185
236
  `upsert_bulk` defaults to `on_failure: :raise`. Use the exact symbol `:return` when the caller needs to
186
237
  handle valid Typesense row failures itself. Malformed responses and response/document count mismatches always
187
238
  raise in either mode; the gem never guesses which submitted document a missing response row belongs to.
@@ -207,6 +258,28 @@ You can control this explicitly with:
207
258
 
208
259
  If you set `SearchEngine.configure { |c| c.client = ... }`, the custom client is always used.
209
260
 
261
+ ## Native search caching
262
+
263
+ The gem defaults to `use_cache: true` and `cache_ttl_s: 60`. Single-search cache options are sent as
264
+ GET parameters; multi-search options apply to the whole request URL, outside individual search bodies.
265
+ Use `Model.cache(false).count` or `.to_a` for fresh maintenance reads.
266
+
267
+ On Typesense 30.1/30.2, cached responses remained stale after document writes and alias swaps until
268
+ expiry or explicit clearing. A direct key's URL `cache_ttl` did not override the native default;
269
+ embed `cache_ttl` in a scoped search key when controlling TTL. Revoked keys fail authentication even
270
+ when a matching response is cached.
271
+
272
+ **Typesense 30.1 has a scoped-cache isolation defect:** identical single/multi-search requests with
273
+ different scoped filters can receive each other's cached results. The same checks pass on 30.2.
274
+ On 30.1, set `c.use_cache = false` or use `.cache(false)` for scoped authorization; upgrade to 30.2
275
+ before enabling it. The integration tests retain strict isolation assertions and expose this server
276
+ failure rather than skipping it. The gem's existing global cache default is unchanged.
277
+
278
+ `SearchEngine::Cache.clear` clears the contacted server's native search cache. For incremental outbox
279
+ writes, `postgres_outbox.clear_cache_after_write` remains opt-in: clearing occurs before database
280
+ acknowledgement, and clearing failures propagate. No Redis response-cache layer is required for
281
+ sharing cached searches across application processes reaching the same Typesense node.
282
+
210
283
  ## Async partition indexing
211
284
 
212
285
  Async partition indexing is an advanced opt-in mode for partitioned full indexing. The default remains
@@ -492,6 +565,22 @@ target-specific status, retry, lock, and queue state for each logical event. Add
492
565
  `create_search_engine_outbox_drain_slots` in that delivery-target migration so the drain enqueuer can cap
493
566
  queued and running drain jobs per target.
494
567
 
568
+ Concurrent listener, scheduler and worker calls share one nonblocking PostgreSQL transaction lock for
569
+ delivery discovery on each events table. Other callers can keep claiming existing deliveries.
570
+ For large histories, create a checkpoint table in a migration with
571
+ `create_search_engine_outbox_materialization_cursors`, then set
572
+ `c.postgres_outbox.materialization_cursor_table_name = 'search_engine_outbox_materialization_cursors'`.
573
+ Run the migration before starting consumers with this setting.
574
+
575
+ With checkpoints, each discovery call checks at most 1,000 IDs around the forward position and
576
+ 1,000 historical IDs against the requested targets. The forward scan overlaps its previous
577
+ range to catch nearby out-of-order commits. Each target set maintains its own position. A
578
+ transaction that commits farther behind, or an older event that becomes due, can wait until
579
+ the historical scan revisits its ID. Sweep time depends on retained history and polling rate.
580
+ Collection batch quotas and the latest-event rule still govern which deliveries may be created.
581
+ Without a configured checkpoint table the existing full-history discovery uses indexed per-event
582
+ delivery checks and the same concurrency lock.
583
+
495
584
  Drain slots are a generic backpressure mechanism for delivery-target mode. On each listener or polling
496
585
  wakeup, the enqueuer materializes missing delivery rows, acquires idle slots from
497
586
  `c.postgres_outbox.drain_slot_table_name`, and enqueues one drain job per acquired slot. If all slots for a
@@ -19,6 +19,7 @@ module SearchEngine
19
19
  # Delegate materializers and query dsl to `.all` so callers can do `Model.first` etc.
20
20
  %i[
21
21
  where rewhere merge order preset ranking prefix search
22
+ where_geo order_geo order_eval vector_search find_similar
22
23
  pin hide curate clear_curation
23
24
  facet_by facet_query group_by unscope
24
25
  limit offset page per_page per options cache
@@ -31,7 +31,7 @@ module SearchEngine
31
31
  # {Response#body}.
32
32
  #
33
33
  # Supported forms (current usage):
34
- # - POST `/collections/:collection/documents/search` (with `common_params`)
34
+ # - GET `/collections/:collection/documents/search` (with URL search/cache parameters)
35
35
  #
36
36
  # @param request [SearchEngine::Client::RequestBuilder::Request] normalized request
37
37
  # @return [Response] response wrapper with status, headers and raw body
@@ -40,22 +40,14 @@ module SearchEngine
40
40
  def perform(request)
41
41
  method = request.http_method.to_sym
42
42
  path = request.path.to_s
43
- if method == :post && path.match?(%r{\A/collections/[^/]+/documents/search\z})
43
+ if method == :get && path.match?(%r{\A/collections/[^/]+/documents/search\z})
44
44
  collection = path.split('/')[2]
45
45
  docs = @typesense.collections[collection].documents
46
- begin
47
- params = docs.method(:search).parameters
48
- supports_common = params.any? { |(kind, _)| %i[key keyreq keyrest].include?(kind) }
49
- rescue StandardError
50
- supports_common = false
51
- end
52
- raw = supports_common ? docs.search(request.body, common_params: request.params) : docs.search(request.body)
46
+ raw = docs.search(request.params)
53
47
  # The official client returns parsed JSON (Hash). No headers/status exposed here.
54
48
  return Response.new(status: 200, headers: {}, body: raw)
55
49
  end
56
50
 
57
- # Fallback: attempt a generic call via low-level typesense endpoint access if available.
58
- # This keeps adapter permissive for future endpoints without adding Faraday here.
59
51
  raise ArgumentError, "Unsupported request path for adapter: #{request.http_method} #{request.path}"
60
52
  end
61
53
  end
@@ -63,17 +63,15 @@ module SearchEngine
63
63
  # @see `https://typesense.org/docs/latest/api/documents.html#search-document`
64
64
  def self.build_search(collection:, compiled_params:, url_opts: {})
65
65
  name = collection.to_s
66
- body_hash = sanitize_body_params(compiled_params.to_h)
67
- # Ensure deterministic ordering even after sanitization
68
- body_json = SearchEngine::CompiledParams.from(body_hash).to_json
66
+ query_params = sanitize_body_params(compiled_params.to_h).merge(url_opts || {})
69
67
 
70
68
  Request.new(
71
- http_method: :post,
69
+ http_method: :get,
72
70
  path: COLLECTIONS_PREFIX + name + DOCUMENTS_SEARCH_SUFFIX,
73
- params: url_opts || {},
71
+ params: query_params,
74
72
  headers: DEFAULT_HEADERS_JSON,
75
- body: body_hash,
76
- body_json: body_json
73
+ body: nil,
74
+ body_json: nil
77
75
  )
78
76
  end
79
77
 
@@ -21,8 +21,8 @@ module SearchEngine
21
21
 
22
22
  result = instrumented_search(collection, params_obj, cache_params, path, payload, start)
23
23
  duration = current_monotonic_ms - start
24
- instrument(:post, path, duration, cache_params, request_token: start)
25
- log_success(:post, path, start, cache_params)
24
+ instrument(:get, path, duration, cache_params, request_token: start)
25
+ log_success(:get, path, start, cache_params)
26
26
 
27
27
  klass = begin
28
28
  SearchEngine.collection_for(collection)
@@ -45,7 +45,7 @@ module SearchEngine
45
45
  bodies = Array(searches).map { |s| sanitize_body_params(s) }
46
46
 
47
47
  result = with_exception_mapping(:post, path, cache_params, start) do
48
- typesense.multi_search.perform({ searches: bodies }, common_params: cache_params)
48
+ typesense.multi_search.perform({ searches: bodies }, cache_params)
49
49
  end
50
50
 
51
51
  instrument(:post, path, current_monotonic_ms - start, cache_params, request_token: start)
@@ -64,7 +64,7 @@ module SearchEngine
64
64
  result = nil
65
65
  SearchEngine::Instrumentation.instrument('search_engine.search', se_payload) do |ctx|
66
66
  ctx[:params_preview] = SearchEngine::Instrumentation.redact(params_obj.to_h)
67
- result = with_exception_mapping(:post, path, cache_params, start) do
67
+ result = with_exception_mapping(:get, path, cache_params, start) do
68
68
  docs = typesense.collections[collection].documents
69
69
  documents_search(docs, payload, cache_params)
70
70
  end
@@ -80,7 +80,7 @@ module SearchEngine
80
80
  end
81
81
  result
82
82
  else
83
- with_exception_mapping(:post, path, cache_params, start) do
83
+ with_exception_mapping(:get, path, cache_params, start) do
84
84
  docs = typesense.collections[collection].documents
85
85
  documents_search(docs, payload, cache_params)
86
86
  end
@@ -170,19 +170,7 @@ module SearchEngine
170
170
  end
171
171
 
172
172
  def documents_search(docs, payload, common_params)
173
- if documents_search_supports_common_params?(docs)
174
- docs.search(payload, common_params: common_params)
175
- else
176
- docs.search(payload)
177
- end
178
- end
179
-
180
- def documents_search_supports_common_params?(docs)
181
- m = docs.method(:search)
182
- params = m.parameters
183
- params.any? { |(kind, _)| %i[key keyreq keyrest].include?(kind) }
184
- rescue StandardError
185
- false
173
+ docs.search(payload.merge(common_params))
186
174
  end
187
175
  end
188
176
  end
@@ -278,6 +278,8 @@ module SearchEngine
278
278
  attr_accessor :delivery_table_name
279
279
  # @return [String] database table used by host-managed drain slots
280
280
  attr_accessor :drain_slot_table_name
281
+ # @return [String, nil] optional checkpoint table for bounded delivery discovery
282
+ attr_accessor :materialization_cursor_table_name
281
283
  # @return [String] PostgreSQL notification channel for wakeups
282
284
  attr_accessor :channel
283
285
  # @return [String] queue name used by host app job dispatch
@@ -936,6 +938,7 @@ module SearchEngine
936
938
  table_name: postgres_outbox.table_name,
937
939
  delivery_table_name: postgres_outbox.delivery_table_name,
938
940
  drain_slot_table_name: postgres_outbox.drain_slot_table_name,
941
+ materialization_cursor_table_name: postgres_outbox.materialization_cursor_table_name,
939
942
  channel: postgres_outbox.channel,
940
943
  queue_name: postgres_outbox.queue_name,
941
944
  batch_size: postgres_outbox.batch_size,
@@ -241,7 +241,7 @@ module SearchEngine
241
241
  client = SearchEngine.client
242
242
  payload = { q: '*', query_by: cfg.default_query_by, per_page: 0, filter_by: filter }
243
243
  params = SearchEngine::CompiledParams.new(payload)
244
- res = client.search(collection: into, params: params, url_opts: {})
244
+ res = client.search(collection: into, params: params, url_opts: { use_cache: false })
245
245
  res&.found
246
246
  rescue StandardError
247
247
  nil
@@ -141,12 +141,21 @@ module SearchEngine
141
141
  # @param vq [String]
142
142
  # @return [String]
143
143
  def self.redact_vector_query(vq)
144
- return vq unless vq.is_a?(String)
145
- return vq if SearchEngine.config.observability.redact_vectors == false
144
+ return vq if vq.nil? || SearchEngine.config.observability.redact_vectors == false
145
+ return '[REDACTED]' unless vq.is_a?(String)
146
146
 
147
- vq.gsub(/\[(-?\d+(?:\.\d+)?(?:\s*,\s*-?\d+(?:\.\d+?))*)\]/) do
148
- dims = Regexp.last_match(1).split(',').size
149
- "[<#{dims} dims>]"
147
+ vector_array = /\A([^:]+:\(\s*)\[([^\[\]]*)\](?=\s*[,)])/
148
+ return '[REDACTED]' unless vq.match?(vector_array)
149
+
150
+ vq.sub(vector_array) do
151
+ prefix = Regexp.last_match(1)
152
+ values = Regexp.last_match(2)
153
+ next "#{prefix}[]" if values.strip.empty?
154
+ next "#{prefix}[#{values}]" if values.match?(/\A<\d+ dims>\z/)
155
+
156
+ numbers = values.split(',', -1).map(&:strip)
157
+ numeric = numbers.all? { |number| number.match?(/\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/) }
158
+ "#{prefix}#{numeric ? "[<#{numbers.size} dims>]" : '[REDACTED]'}"
150
159
  end
151
160
  end
152
161
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module SearchEngine
7
+ module PostgresOutbox
8
+ # Read under the outbox materialization lock. One page follows new event IDs;
9
+ # the other sweeps backwards to revisit late commits, due retries and existing events.
10
+ class MaterializationCursor
11
+ PAGE_SIZE = 1000
12
+
13
+ def initialize(connection, events_table, cursor_table, target_keys)
14
+ @connection = connection
15
+ @events = events_table
16
+ @table = connection.quote_table_name(cursor_table)
17
+ @scope = connection.quote(Digest::SHA256.hexdigest(JSON.generate([events_table, target_keys.uniq.sort])))
18
+ end
19
+
20
+ # @param limit [Integer] upper bound on the number of event IDs to read in each direction
21
+ # @return [Array<Integer>] event IDs to check against the configured delivery targets
22
+ def candidate_ids(limit:)
23
+ @page_size = [PAGE_SIZE, limit].min
24
+ state = @connection.select_all(<<~SQL).first
25
+ SELECT forward_id, backfill_through_id FROM #{@table} WHERE target_scope = #{@scope}
26
+ SQL
27
+ if state
28
+ @forward_id = Integer(state.fetch('forward_id'))
29
+ @backfill_through_id = Integer(state.fetch('backfill_through_id'))
30
+ else
31
+ @forward_id, @backfill_through_id = initialize_positions
32
+ end
33
+
34
+ # Recheck a recent ID range for concurrent inserts that commit behind the watermark.
35
+ # Even for dense IDs, at least half of the page remains available for new events.
36
+ @forward_page = event_ids("id > #{@forward_id - @page_size / 2} ORDER BY id ASC")
37
+ @backfill_page = event_ids("id <= #{@backfill_through_id} ORDER BY id DESC")
38
+ @forward_page | @backfill_page
39
+ end
40
+
41
+ # Save progress in the same transaction as the new deliveries.
42
+ def advance!
43
+ forward = [@forward_page.last || @forward_id, @forward_id].max
44
+ # Freeze the upper boundary until the backwards sweep reaches the beginning.
45
+ backfill = @backfill_page.size < @page_size ? forward : @backfill_page.last - 1
46
+ return if forward == @forward_id && backfill == @backfill_through_id
47
+
48
+ @connection.execute(<<~SQL)
49
+ UPDATE #{@table} SET forward_id = #{forward}, backfill_through_id = #{backfill}
50
+ WHERE target_scope = #{@scope}
51
+ SQL
52
+ end
53
+
54
+ private
55
+
56
+ def initialize_positions
57
+ head = Integer(@connection.select_value("SELECT COALESCE(MAX(id), 0) FROM #{@events}"))
58
+ @connection.execute(<<~SQL)
59
+ INSERT INTO #{@table} (target_scope, forward_id, backfill_through_id)
60
+ VALUES (#{@scope}, #{head}, #{head})
61
+ SQL
62
+ [head, head]
63
+ end
64
+
65
+ def event_ids(order)
66
+ @connection.select_all("SELECT id FROM #{@events} WHERE #{order} LIMIT #{@page_size}").map do |row|
67
+ Integer(row.fetch('id'))
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -102,6 +102,15 @@ module SearchEngine
102
102
  on_delete: :cascade
103
103
  end
104
104
 
105
+ # Create the small checkpoint table used for bounded delivery discovery.
106
+ # @param table_name [String, Symbol] table configured as materialization_cursor_table_name
107
+ def create_search_engine_outbox_materialization_cursors(table_name: :search_engine_outbox_materialization_cursors)
108
+ create_table table_name, id: :string, primary_key: :target_scope do |t|
109
+ t.bigint :forward_id, null: false
110
+ t.bigint :backfill_through_id, null: false
111
+ end
112
+ end
113
+
105
114
  # Create the SearchEngine outbox drain slots table.
106
115
  #
107
116
  # @param table_name [String, Symbol] destination drain slots table name
@@ -9,6 +9,7 @@ module SearchEngine
9
9
  class Repository
10
10
  # Keep expensive duplicate-key ranking bounded while leaving headroom for hot keys.
11
11
  CLAIM_CANDIDATE_OVERSAMPLE = 4
12
+ MATERIALIZATION_LOCK_NAMESPACE = 0x53454F42 # SEOB
12
13
  ERROR_LIMIT = 1000
13
14
  PROCESSED_AT_ASSIGNMENT = 'processed_at = CURRENT_TIMESTAMP'
14
15
  RETIREMENT_BATCH_SIZE = 1000
@@ -181,43 +182,27 @@ module SearchEngine
181
182
  with_delivery_target_session_lock(target, mode: :shared, timing: timing, &block)
182
183
  end
183
184
 
184
- # Create missing delivery rows for all configured delivery targets.
185
- # @return [void]
185
+ # Create missing deliveries. Concurrent pollers leave discovery to the current owner.
186
+ # @return [Array<Hash>, nil] materialized events, or nil when no targets are configured
186
187
  def materialize_deliveries!(limit: nil)
187
188
  targets = materialization_delivery_targets
188
189
  return if targets.empty?
189
190
 
190
- # Discover work without row locks, then acquire every affected parent before any delivery.
191
- # The exact preview is revalidated under those parent locks before mutations begin.
192
- preview_rows = select_rows(delivery_materialization_select_sql(limit, targets, lock: false))
193
- return preview_rows if preview_rows.empty?
191
+ scan_limit = collection_limited_batch?(limit) ? collection_limited_candidate_limit : global_limit_for(limit)
192
+ return [] if scan_limit.zero?
194
193
 
195
- supersede_parents = select_rows(supersede_parent_ids_sql(preview_rows))
196
- rows = []
197
194
  connection.transaction do
198
- parent_ids = preview_rows.map { |row| row_value(row, :id) }
199
- parent_ids.concat(supersede_parents.map { |row| row_value(row, :event_id) })
200
- lock_event_rows!(parent_ids)
201
-
202
- rows = select_rows(
203
- delivery_materialization_select_sql(
204
- limit,
205
- targets,
206
- event_ids: preview_rows.map { |row| row_value(row, :id) }
207
- )
208
- )
209
- next if rows.empty?
210
-
211
- supersede_candidates = select_rows(materialization_supersede_parent_candidates_sql(rows, targets))
212
- older_event_ids = supersede_candidates.map { |candidate| row_value(candidate, :event_id) }
213
- older_delivery_ids = supersede_candidates.map { |candidate| row_value(candidate, :delivery_id) }
214
- lock_delivery_rows!(older_delivery_ids)
215
- supersede_pending_events_by_id!(older_event_ids)
216
- supersede_pending_deliveries!(older_delivery_ids, event_ids: older_event_ids)
217
- execute(delivery_materialization_insert_sql(rows, targets))
195
+ next [] unless materialization_lock_acquired?
196
+
197
+ cursor = materialization_cursor(targets)
198
+ event_ids = cursor&.candidate_ids(limit: scan_limit)
199
+ preview = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: event_ids, lock: false))
200
+ rows = preview.empty? ? [] : materialize_batch(limit, targets, preview)
201
+ if cursor && rows.size == preview.size && materialization_page_drained?(rows, event_ids, limit)
202
+ cursor.advance!
203
+ end
204
+ rows
218
205
  end
219
-
220
- rows
221
206
  end
222
207
 
223
208
  # Refresh cleanup-eligible parent event statuses from terminal delivery rows.
@@ -415,6 +400,49 @@ module SearchEngine
415
400
  end
416
401
  end
417
402
 
403
+ # PostgreSQL's two-integer lock space is separate from the single-key delivery cutover locks.
404
+ def materialization_lock_acquired?
405
+ sql = <<~SQL
406
+ SELECT pg_try_advisory_xact_lock(#{MATERIALIZATION_LOCK_NAMESPACE}, #{quote(quoted_table)}::regclass::integer)
407
+ SQL
408
+ advisory_lock_truthy?(select_scalar(sql))
409
+ end
410
+
411
+ def materialization_cursor(targets)
412
+ table = SearchEngine.config.postgres_outbox.materialization_cursor_table_name
413
+ MaterializationCursor.new(connection, quoted_table, table, targets.map(&:key)) if table
414
+ end
415
+
416
+ # When a quota may have omitted matches, revisit the page after these deliveries commit.
417
+ def materialization_page_drained?(rows, event_ids, limit)
418
+ return true if rows.empty? || rows.size == event_ids.size
419
+
420
+ batch_size = global_limit_for(limit)
421
+ return rows.size < batch_size unless collection_limited_batch?(limit)
422
+
423
+ per_collection = SearchEngine.config.postgres_outbox.normalized_batch_sizes.values
424
+ rows.size < [batch_size, *per_collection].min
425
+ end
426
+
427
+ # Revalidate the exact preview under parent locks before locking any deliveries.
428
+ def materialize_batch(limit, targets, preview_rows)
429
+ supersede_parents = select_rows(supersede_parent_ids_sql(preview_rows))
430
+ preview_ids = preview_rows.map { |row| row_value(row, :id) }
431
+ lock_event_rows!(preview_ids + supersede_parents.map { |row| row_value(row, :event_id) })
432
+
433
+ rows = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: preview_ids))
434
+ return rows if rows.empty?
435
+
436
+ supersede_candidates = select_rows(materialization_supersede_parent_candidates_sql(rows, targets))
437
+ older_event_ids = supersede_candidates.map { |candidate| row_value(candidate, :event_id) }
438
+ older_delivery_ids = supersede_candidates.map { |candidate| row_value(candidate, :delivery_id) }
439
+ lock_delivery_rows!(older_delivery_ids)
440
+ supersede_pending_events_by_id!(older_event_ids)
441
+ supersede_pending_deliveries!(older_delivery_ids, event_ids: older_event_ids)
442
+ execute(delivery_materialization_insert_sql(rows, targets))
443
+ rows
444
+ end
445
+
418
446
  def claim_pending_deliveries(limit:, worker_id:)
419
447
  rows = claim_pending_delivery_rows(limit: limit, worker_id: worker_id)
420
448
 
@@ -431,51 +459,34 @@ module SearchEngine
431
459
  return collection_limited_materialization_select_sql(targets, event_ids: event_ids, lock: lock)
432
460
  end
433
461
 
434
- limit = global_limit_for(limit)
435
462
  <<~SQL
436
- WITH target(target_key, queue_name) AS (
437
- VALUES #{delivery_target_values_sql(targets)}
438
- ),
439
- candidate_events AS MATERIALIZED (
440
- SELECT outbox.id,
441
- outbox.collection,
442
- outbox.document_id
443
- FROM #{quoted_table} outbox
444
- WHERE outbox.status IN ('pending', 'processing', 'failed')
445
- AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
446
- #{newer_event_exclusion_sql('outbox')}
447
- #{event_id_filter_sql('outbox', event_ids)}
448
- AND EXISTS (
449
- SELECT 1
450
- FROM target
451
- WHERE NOT EXISTS (
452
- SELECT 1
453
- FROM #{quoted_delivery_table} deliveries
454
- WHERE deliveries.event_id = outbox.id
455
- AND deliveries.target_key = target.target_key
456
- )
457
- )
458
- ORDER BY outbox.id ASC
459
- LIMIT #{limit}
460
- #{row_lock_sql(lock)}
461
- ),
462
- latest_candidate_ids AS (
463
- SELECT id
464
- FROM (
465
- SELECT id,
466
- ROW_NUMBER() OVER (
467
- PARTITION BY collection, document_id
468
- ORDER BY id DESC
469
- ) AS row_number
470
- FROM candidate_events
471
- ) ranked_candidate_events
472
- WHERE row_number = 1
473
- )
474
- SELECT outbox.*
463
+ SELECT #{lock ? 'outbox.*' : 'outbox.id, outbox.collection, outbox.document_id'}
475
464
  FROM #{quoted_table} outbox
476
- INNER JOIN latest_candidate_ids
477
- ON latest_candidate_ids.id = outbox.id
465
+ WHERE #{materialization_filter_sql(targets, event_ids)}
478
466
  ORDER BY outbox.id ASC
467
+ LIMIT #{global_limit_for(limit)}
468
+ #{row_lock_sql(lock)}
469
+ SQL
470
+ end
471
+
472
+ # Each membership check reads at most one unique delivery per requested key.
473
+ # The scalar newer-event probe also prevents a hash over the entire parent history.
474
+ def materialization_filter_sql(targets, event_ids)
475
+ keys = targets.map(&:key).uniq
476
+ <<~SQL.chomp
477
+ outbox.status IN ('pending', 'processing', 'failed')
478
+ AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
479
+ #{event_id_filter_sql('outbox', event_ids)}
480
+ AND (
481
+ SELECT newer.id FROM #{quoted_table} newer
482
+ WHERE newer.collection = outbox.collection AND newer.document_id = outbox.document_id
483
+ AND newer.id > outbox.id
484
+ LIMIT 1
485
+ ) IS NULL
486
+ AND (
487
+ SELECT COUNT(*) FROM #{quoted_delivery_table} deliveries
488
+ WHERE deliveries.event_id = outbox.id AND deliveries.target_key IN (#{ids_sql(keys)})
489
+ ) < #{keys.size}
479
490
  SQL
480
491
  end
481
492
 
@@ -683,6 +694,8 @@ module SearchEngine
683
694
  end
684
695
 
685
696
  def select_scalar(sql)
697
+ # Advisory locks and quiescence polls must reach the current database session on every attempt.
698
+ return connection.exec_query(sql).rows.first&.first if connection.respond_to?(:exec_query)
686
699
  return connection.select_value(sql) if connection.respond_to?(:select_value)
687
700
 
688
701
  result = connection.execute(sql)
@@ -1594,64 +1607,32 @@ module SearchEngine
1594
1607
 
1595
1608
  def collection_limited_materialization_select_sql(targets, event_ids: nil, lock: true)
1596
1609
  <<~SQL
1597
- WITH target(target_key, queue_name) AS (
1598
- VALUES #{delivery_target_values_sql(targets)}
1599
- ),
1600
- #{collection_limits_cte_sql},
1610
+ WITH #{collection_limits_cte_sql},
1601
1611
  candidate_events AS MATERIALIZED (
1602
- SELECT outbox.id,
1603
- outbox.collection,
1604
- outbox.document_id
1612
+ SELECT outbox.id, outbox.collection
1605
1613
  FROM #{quoted_table} outbox
1606
- WHERE outbox.status IN ('pending', 'processing', 'failed')
1607
- AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
1608
- #{newer_event_exclusion_sql('outbox')}
1609
- #{event_id_filter_sql('outbox', event_ids)}
1610
- AND EXISTS (
1611
- SELECT 1
1612
- FROM target
1613
- WHERE NOT EXISTS (
1614
- SELECT 1
1615
- FROM #{quoted_delivery_table} deliveries
1616
- WHERE deliveries.event_id = outbox.id
1617
- AND deliveries.target_key = target.target_key
1618
- )
1619
- )
1614
+ WHERE #{materialization_filter_sql(targets, event_ids)}
1620
1615
  ORDER BY outbox.id ASC
1621
1616
  LIMIT #{collection_limited_candidate_limit}
1622
1617
  #{row_lock_sql(lock)}
1623
1618
  ),
1624
- latest_candidate_ids AS (
1625
- SELECT id,
1626
- collection
1627
- FROM (
1628
- SELECT id,
1629
- collection,
1630
- ROW_NUMBER() OVER (
1631
- PARTITION BY collection, document_id
1632
- ORDER BY id DESC
1633
- ) AS row_number
1634
- FROM candidate_events
1635
- ) ranked_candidate_events
1636
- WHERE row_number = 1
1637
- ),
1638
1619
  ranked_by_collection AS (
1639
- SELECT latest_candidate_ids.id,
1620
+ SELECT candidate_events.id,
1640
1621
  ROW_NUMBER() OVER (
1641
- PARTITION BY latest_candidate_ids.collection
1642
- ORDER BY latest_candidate_ids.id ASC
1622
+ PARTITION BY candidate_events.collection
1623
+ ORDER BY candidate_events.id ASC
1643
1624
  ) AS collection_row_number,
1644
1625
  COALESCE(collection_limits.batch_size, #{global_limit_for(nil)}) AS collection_batch_size
1645
- FROM latest_candidate_ids
1626
+ FROM candidate_events
1646
1627
  LEFT JOIN collection_limits
1647
- ON collection_limits.collection = latest_candidate_ids.collection
1628
+ ON collection_limits.collection = candidate_events.collection
1648
1629
  ),
1649
1630
  selected_candidate_ids AS (
1650
1631
  SELECT id
1651
1632
  FROM ranked_by_collection
1652
1633
  WHERE collection_row_number <= collection_batch_size
1653
1634
  )
1654
- SELECT outbox.*
1635
+ SELECT #{lock ? 'outbox.*' : 'outbox.id, outbox.collection, outbox.document_id'}
1655
1636
  FROM #{quoted_table} outbox
1656
1637
  INNER JOIN selected_candidate_ids
1657
1638
  ON selected_candidate_ids.id = outbox.id
@@ -1784,18 +1765,6 @@ module SearchEngine
1784
1765
  Array(ids).compact.uniq(&:to_s)
1785
1766
  end
1786
1767
 
1787
- def newer_event_exclusion_sql(event_relation)
1788
- <<~SQL.chomp
1789
- AND NOT EXISTS (
1790
- SELECT 1
1791
- FROM #{quoted_table} newer_events
1792
- WHERE newer_events.collection = #{event_relation}.collection
1793
- AND newer_events.document_id = #{event_relation}.document_id
1794
- AND newer_events.id > #{event_relation}.id
1795
- )
1796
- SQL
1797
- end
1798
-
1799
1768
  def processing_event_exclusion_sql(event_relation)
1800
1769
  <<~SQL.chomp
1801
1770
  AND NOT EXISTS (
@@ -3,6 +3,7 @@
3
3
  require 'search_engine/postgres_outbox/event'
4
4
  require 'search_engine/postgres_outbox/delivery_target'
5
5
  require 'search_engine/postgres_outbox/processor_result'
6
+ require 'search_engine/postgres_outbox/materialization_cursor'
6
7
  require 'search_engine/postgres_outbox/repository'
7
8
  require 'search_engine/postgres_outbox/event_processor'
8
9
  require 'search_engine/postgres_outbox/drainer'
@@ -518,7 +518,6 @@ module SearchEngine
518
518
 
519
519
  auto_include_embedding_in_query_by!(params, vq, field)
520
520
  auto_exclude_embedding_from_response!(params, field)
521
- resolve_vector_distance_sort!(params, field)
522
521
  instrument_vector_compile(vq, params, field)
523
522
  end
524
523
 
@@ -538,7 +537,10 @@ module SearchEngine
538
537
  parts << "#{key}:#{vq[key]}" if vq.key?(key)
539
538
  end
540
539
  parts << "alpha:#{vq[:alpha]}" if vq.key?(:alpha) && hybrid_vector_mode?(params, vq)
541
- parts << "queries:[#{vq[:queries].join(', ')}]" if vq[:queries]
540
+ if vq[:queries]
541
+ queries = vq[:queries].map { |query| "`#{query}`" }
542
+ parts << "queries:[#{queries.join(', ')}]"
543
+ end
542
544
  parts << "query_weights:[#{vq[:weights].join(', ')}]" if vq[:weights]
543
545
 
544
546
  inner = [vector_part, *parts].join(', ')
@@ -547,9 +549,10 @@ module SearchEngine
547
549
 
548
550
  # Hybrid mode: auto-append the embedding field to `query_by` so that
549
551
  # Typesense performs rank-fusion between keyword and vector results.
550
- # Hybrid = text query present AND no explicit embedding vector supplied.
552
+ # Explicit vectors already supply the embedding and need no query_by addition.
551
553
  def auto_include_embedding_in_query_by!(params, vq, field)
552
554
  return unless hybrid_vector_mode?(params, vq)
555
+ return if vq.key?(:query)
553
556
 
554
557
  current = params[:query_by].to_s
555
558
  fields = current.split(',').map(&:strip)
@@ -571,19 +574,8 @@ module SearchEngine
571
574
  params[:exclude_fields] = current.empty? ? field : "#{current},#{field}"
572
575
  end
573
576
 
574
- # Replace the `_vector_distance` sentinel produced by order normalization
575
- # with the real Typesense sort token that references the embedding field.
576
- def resolve_vector_distance_sort!(params, vq_field)
577
- sort = params[:sort_by]
578
- return unless sort&.include?(VECTOR_DISTANCE_SENTINEL)
579
-
580
- params[:sort_by] = sort.gsub(/#{VECTOR_DISTANCE_SENTINEL}:(asc|desc)/) do
581
- "_vector_query(#{vq_field}:([])):#{Regexp.last_match(1)}"
582
- end
583
- end
584
-
585
577
  def hybrid_vector_mode?(params, vq)
586
- params[:q].to_s != '*' && !vq.key?(:query) && !vq.key?(:id) && !vq.key?(:queries)
578
+ params[:q].to_s != '*' && !vq.key?(:id) && !vq.key?(:queries)
587
579
  end
588
580
 
589
581
  def explicitly_selected_field?(field_name)
@@ -63,6 +63,9 @@ module SearchEngine
63
63
 
64
64
  validate_vector_query_mode_exclusivity!(query, id, queries)
65
65
  validate_vector_query_array!(query)
66
+ validate_vector_dimensions!(field_sym, query) if query
67
+ normalized_id = id.to_s unless id.nil?
68
+ validate_vector_id!(normalized_id)
66
69
  validate_vector_queries!(queries)
67
70
  validate_vector_weights!(weights, queries)
68
71
 
@@ -75,11 +78,11 @@ module SearchEngine
75
78
  result = { field: field_sym }
76
79
  result[:k] = coerced_k if coerced_k
77
80
  result[:alpha] = Float(alpha) if alpha
78
- result[:query] = query if query
79
- result[:id] = id.to_s if id
81
+ result[:query] = query.map(&:to_f) if query
82
+ result[:id] = normalized_id.dup unless normalized_id.nil?
80
83
  result[:distance_threshold] = Float(distance_threshold) if distance_threshold
81
- result[:queries] = queries if queries
82
- result[:weights] = weights if weights
84
+ result[:queries] = queries.map(&:dup) if queries
85
+ result[:weights] = weights.map(&:to_f) if weights
83
86
  result[:ef] = coerced_ef if coerced_ef
84
87
  result[:flat_search_cutoff] = coerced_flat if coerced_flat
85
88
  result
@@ -106,11 +109,19 @@ module SearchEngine
106
109
 
107
110
  sym = field.to_sym
108
111
  attrs = safe_attributes_map
109
- return if attrs.nil? || attrs.empty?
112
+ return if attrs.nil?
110
113
 
111
114
  embeddings = vector_embeddings_map
112
115
 
113
- return if attrs.key?(sym) || embeddings.key?(sym)
116
+ return if embeddings.key?(sym) || [:vector, [:float], :'float[]', 'float[]'].include?(attrs[sym])
117
+
118
+ if attrs.key?(sym)
119
+ raise SearchEngine::Errors::InvalidVectorQuery.new(
120
+ "InvalidVectorQuery: field :#{sym} must be a vector or float array",
121
+ doc: VECTOR_SEARCH_DOC_URL,
122
+ details: { field: sym, type: attrs[sym] }
123
+ )
124
+ end
114
125
 
115
126
  known = (attrs.keys + embeddings.keys).uniq
116
127
  suggestions = suggest_fields(sym, known)
@@ -128,9 +139,9 @@ module SearchEngine
128
139
 
129
140
  def validate_vector_query_mode_exclusivity!(query, id, queries)
130
141
  modes = []
131
- modes << :query if query
132
- modes << :id if id
133
- modes << :queries if queries
142
+ modes << :query unless query.nil?
143
+ modes << :id unless id.nil?
144
+ modes << :queries unless queries.nil?
134
145
  return if modes.length <= 1
135
146
 
136
147
  raise SearchEngine::Errors::InvalidVectorQuery.new(
@@ -146,9 +157,9 @@ module SearchEngine
146
157
  def validate_vector_query_array!(query)
147
158
  return if query.nil?
148
159
 
149
- unless query.is_a?(Array) && query.all? { |v| v.is_a?(Numeric) }
160
+ unless query.is_a?(Array) && query.all? { |v| finite_vector_number?(v) }
150
161
  raise SearchEngine::Errors::InvalidVectorQuery.new(
151
- 'InvalidVectorQuery: query: must be an Array of Numeric values',
162
+ 'InvalidVectorQuery: query: must be an Array of finite real numbers',
152
163
  hint: 'Pass a float array like [0.1, 0.2, ...]',
153
164
  doc: VECTOR_SEARCH_DOC_URL,
154
165
  details: { query_class: query.class.name }
@@ -164,6 +175,35 @@ module SearchEngine
164
175
  )
165
176
  end
166
177
 
178
+ def finite_vector_number?(value)
179
+ value.is_a?(Numeric) && value.real? && value.to_f.finite?
180
+ end
181
+
182
+ def validate_vector_dimensions!(field, query)
183
+ metadata = vector_embeddings_map[field] || {}
184
+ options = @klass.respond_to?(:attribute_options) ? @klass.attribute_options[field] || {} : {}
185
+ dimension = metadata[:num_dim] || options[:num_dim]
186
+ return if dimension.nil? || query.size == Integer(dimension)
187
+
188
+ raise SearchEngine::Errors::InvalidVectorQuery.new(
189
+ "InvalidVectorQuery: field :#{field} expects #{dimension} dimensions (got #{query.size})",
190
+ doc: VECTOR_SEARCH_DOC_URL,
191
+ details: { field: field, expected: dimension, actual: query.size }
192
+ )
193
+ end
194
+
195
+ def validate_vector_id!(id)
196
+ return if id.nil?
197
+
198
+ value = id.to_s
199
+ return if !value.empty? && value == value.strip && !value.match?(/[:,()[:cntrl:]]/)
200
+
201
+ raise SearchEngine::Errors::InvalidVectorQuery.new(
202
+ 'InvalidVectorQuery: id: cannot be blank or contain vector-query delimiters or surrounding whitespace',
203
+ doc: VECTOR_SEARCH_DOC_URL
204
+ )
205
+ end
206
+
167
207
  def validate_vector_queries!(queries)
168
208
  return if queries.nil?
169
209
 
@@ -183,6 +223,14 @@ module SearchEngine
183
223
  )
184
224
  end
185
225
 
226
+ if queries.any? { |query| query != query.strip || query.match?(/[:`[:cntrl:]]/) }
227
+ raise SearchEngine::Errors::InvalidVectorQuery.new(
228
+ 'InvalidVectorQuery: queries: cannot contain unsupported delimiters or surrounding whitespace',
229
+ hint: 'Colons, backticks and control characters cannot be represented in historical queries',
230
+ doc: VECTOR_SEARCH_DOC_URL
231
+ )
232
+ end
233
+
186
234
  blank_indices = queries.each_index.select { |i| queries[i].strip.empty? }
187
235
  return if blank_indices.empty?
188
236
 
@@ -197,9 +245,9 @@ module SearchEngine
197
245
  def validate_vector_weights!(weights, queries)
198
246
  return if weights.nil?
199
247
 
200
- unless weights.is_a?(Array) && weights.all? { |v| v.is_a?(Numeric) }
248
+ unless weights.is_a?(Array) && weights.all? { |v| finite_vector_number?(v) }
201
249
  raise SearchEngine::Errors::InvalidVectorQuery.new(
202
- 'InvalidVectorQuery: weights: must be an Array of Numeric',
250
+ 'InvalidVectorQuery: weights: must be an Array of finite real numbers',
203
251
  doc: VECTOR_SEARCH_DOC_URL,
204
252
  details: { weights_class: weights.class.name }
205
253
  )
@@ -221,7 +269,7 @@ module SearchEngine
221
269
  )
222
270
  end
223
271
 
224
- sum = weights.sum.to_f
272
+ sum = weights.sum(&:to_f)
225
273
  tolerance = SearchEngine.config.embedding.weights_sum_tolerance
226
274
  return if (sum - 1.0).abs <= tolerance
227
275
 
@@ -239,7 +287,7 @@ module SearchEngine
239
287
 
240
288
  begin
241
289
  fv = Float(alpha)
242
- rescue ArgumentError, TypeError
290
+ rescue ArgumentError, TypeError, RangeError
243
291
  raise SearchEngine::Errors::InvalidVectorQuery.new(
244
292
  "InvalidVectorQuery: alpha: must be a Numeric between 0.0 and 1.0 (got #{alpha.inspect})",
245
293
  doc: VECTOR_SEARCH_DOC_URL,
@@ -262,7 +310,7 @@ module SearchEngine
262
310
 
263
311
  begin
264
312
  fv = Float(distance_threshold)
265
- rescue ArgumentError, TypeError
313
+ rescue ArgumentError, TypeError, RangeError
266
314
  raise SearchEngine::Errors::InvalidVectorQuery.new(
267
315
  'InvalidVectorQuery: distance_threshold: must be a non-negative Numeric ' \
268
316
  "(got #{distance_threshold.inspect})",
@@ -285,7 +333,7 @@ module SearchEngine
285
333
 
286
334
  begin
287
335
  iv = Integer(value)
288
- rescue ArgumentError, TypeError
336
+ rescue ArgumentError, TypeError, RangeError
289
337
  raise SearchEngine::Errors::InvalidVectorQuery.new(
290
338
  "InvalidVectorQuery: #{name}: must be a positive Integer (got #{value.inspect})",
291
339
  doc: VECTOR_SEARCH_DOC_URL,
@@ -293,10 +341,10 @@ module SearchEngine
293
341
  )
294
342
  end
295
343
 
296
- return iv if iv.positive?
344
+ return iv if iv.positive? && (value.is_a?(String) || value == iv)
297
345
 
298
346
  raise SearchEngine::Errors::InvalidVectorQuery.new(
299
- "InvalidVectorQuery: #{name}: must be > 0 (got #{iv})",
347
+ "InvalidVectorQuery: #{name}: must be a positive Integer without truncation",
300
348
  doc: VECTOR_SEARCH_DOC_URL,
301
349
  details: { name => iv }
302
350
  )
@@ -342,8 +390,6 @@ module SearchEngine
342
390
  return {} unless @klass.respond_to?(:embeddings_config)
343
391
 
344
392
  @klass.embeddings_config || {}
345
- rescue StandardError
346
- {}
347
393
  end
348
394
 
349
395
  def build_vector_field_suggestion(suggestions)
@@ -548,8 +548,7 @@ module SearchEngine
548
548
  ORDER_ALIASES = { 'text_match' => '_text_match', 'vector_distance' => '_vector_distance' }.freeze
549
549
 
550
550
  # Map DSL sort aliases to Typesense internal tokens.
551
- # `_vector_distance` is a sentinel resolved by the compiler once the
552
- # embedding field name is known.
551
+ # `_vector_distance` sorts the active vector query without re-embedding it.
553
552
  def resolve_order_alias(field)
554
553
  ORDER_ALIASES.fetch(field, field)
555
554
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'shellwords'
4
5
 
5
6
  module SearchEngine
6
7
  class Relation
@@ -36,13 +37,17 @@ module SearchEngine
36
37
  end
37
38
  end
38
39
 
39
- # Build a single-line curl string with redacted body.
40
+ # Build a shell-quoted GET search command with redacted URL parameters.
40
41
  # @param url [String]
41
42
  # @param params [Hash]
43
+ # @param url_opts [Hash] request-level cache controls
42
44
  # @return [String]
43
- def self.curl(url, params)
44
- body_json = JSON.generate(redact_params(params))
45
- %(curl -X POST #{url} -H 'Content-Type: application/json' -H 'X-TYPESENSE-API-KEY: ***' -d '#{body_json}')
45
+ def self.curl(url, params, url_opts: {})
46
+ query = redact_params(params).reject { |key, _value| key == :_hits }
47
+ query.merge!(SearchEngine::Observability.filtered_url_opts(url_opts))
48
+ arguments = ['curl', '--get', url, '-H', 'X-TYPESENSE-API-KEY: ***']
49
+ query.each { |key, value| arguments.concat(['--data-urlencode', "#{key}=#{value}"]) }
50
+ Shellwords.join(arguments)
46
51
  end
47
52
 
48
53
  # Return a structured dry-run payload with redacted JSON body and url options.
@@ -11,7 +11,7 @@ module SearchEngine
11
11
  # DX helpers are mixed into `Relation` to offer redaction‑aware, zero‑I/O explain and preview utilities.
12
12
  # These helpers are pure and never mutate relation state.
13
13
  module Dx
14
- # Return the request body JSON after compile, fully redacted.
14
+ # Return the compiled search parameters as fully redacted JSON.
15
15
  # @param pretty [Boolean] pretty-print with stable key ordering when true
16
16
  # @return [String]
17
17
  # @since M8
@@ -22,14 +22,14 @@ module SearchEngine
22
22
  SearchEngine::Relation::Dx::DryRun.to_json(redacted, pretty: pretty)
23
23
  end
24
24
 
25
- # Return a single-line curl command with redacted API key and JSON body.
25
+ # Return a shell-quoted GET curl command with redacted API key and parameters.
26
26
  # @return [String]
27
27
  # @since M8
28
28
  # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/dx
29
29
  def to_curl
30
30
  url = compiled_url
31
31
  params = SearchEngine::CompiledParams.from(to_typesense_params).to_h
32
- SearchEngine::Relation::Dx::DryRun.curl(url, params)
32
+ SearchEngine::Relation::Dx::DryRun.curl(url, params, url_opts: compiled_url_opts)
33
33
  end
34
34
 
35
35
  # Compile and validate without performing network I/O.
@@ -96,6 +96,7 @@ module SearchEngine
96
96
  attach_curation!(obj, entry)
97
97
  attach_highlighting!(obj, entry)
98
98
  attach_geo_distance!(obj, entry)
99
+ attach_vector_distance!(obj, entry)
99
100
  hydrated << obj
100
101
  end
101
102
  @hits = hydrated.freeze
@@ -296,6 +297,14 @@ module SearchEngine
296
297
  end
297
298
  end
298
299
 
300
+ # Backend vector distance metadata, distinct from document attributes.
301
+ module VectorDistance
302
+ # @return [Numeric, nil] the unmodified distance supplied by Typesense
303
+ def vector_distance
304
+ instance_variable_get(:@__se_vector_distance__)
305
+ end
306
+ end
307
+
299
308
  # Per-hit curation mixin: added onto hydrated objects when Typesense
300
309
  # returns curated metadata for the hit.
301
310
  module HitCuration
@@ -414,6 +423,7 @@ module SearchEngine
414
423
  attach_curation!(obj, sym_sub)
415
424
  attach_highlighting!(obj, sym_sub)
416
425
  attach_geo_distance!(obj, sym_sub)
426
+ attach_vector_distance!(obj, sym_sub)
417
427
  hydrated << obj
418
428
  end
419
429
 
@@ -569,6 +579,14 @@ module SearchEngine
569
579
  obj
570
580
  end
571
581
 
582
+ def attach_vector_distance!(obj, hit_entry)
583
+ return obj unless hit_entry.key?(:vector_distance)
584
+
585
+ obj.singleton_class.prepend(VectorDistance)
586
+ obj.instance_variable_set(:@__se_vector_distance__, hit_entry[:vector_distance])
587
+ obj
588
+ end
589
+
572
590
  def attach_geo_distance!(obj, hit_entry)
573
591
  raw_geo = hit_entry[:geo_distance_meters]
574
592
  return obj unless raw_geo.is_a?(Hash) && !raw_geo.empty?
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.8.24'
6
+ VERSION = '30.9.1'
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: search-engine-for-typesense
3
3
  version: !ruby/object:Gem::Version
4
- version: 30.8.24
4
+ version: 30.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikita Shkoda
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -189,6 +189,7 @@ files:
189
189
  - lib/search_engine/postgres_outbox/event.rb
190
190
  - lib/search_engine/postgres_outbox/event_processor.rb
191
191
  - lib/search_engine/postgres_outbox/listener.rb
192
+ - lib/search_engine/postgres_outbox/materialization_cursor.rb
192
193
  - lib/search_engine/postgres_outbox/migration_helpers.rb
193
194
  - lib/search_engine/postgres_outbox/processor_result.rb
194
195
  - lib/search_engine/postgres_outbox/repository.rb